diff --git a/COMPARISON.md b/COMPARISON.md index 614878d..983b8f2 100644 --- a/COMPARISON.md +++ b/COMPARISON.md @@ -6,19 +6,19 @@ Repolicense CLI originated as a terminal port of the [repolicense web applicatio | Area | Original web application | Current CLI | | --- | --- | --- | -| Interaction | Browser yes/no controls | Terminal answers, `back`, `reset`, and `quit` | +| Interaction | Browser yes/no controls | Optional Gum menus or standalone numbered/label prompts; back/restart/quit | | State | Browser session storage | In-memory session history | | License details | GitHub API integration | Local explanations and SPDX license-text links; no network calls | -| Implementation | JavaScript, HTML/CSS, Shoelace | Zig 0.17.0 and the standard library | -| Tree representation | JavaScript objects | Static typed nodes with canonical license metadata | +| Implementation | JavaScript, HTML/CSS, Shoelace | Zig 0.17.0 and standard library; optional external Gum executable | +| Tree representation | JavaScript objects | Static typed menu graph with canonical license metadata | | Compatibility | Not part of the original port's web interface | `--compat` / `-c`, all-input target assessment and conditional prerequisites | | Distribution | Browser application | Native executable; Linux, macOS, and Windows CI | ## Intentional decision-tree changes -The [current tree](./TREE_STRUCTURE.md) separates fonts from software immediately, checks software licensing rights, and distinguishes concrete patent/notice obligations before recommending a permissive license. Copyleft paths distinguish combined programs, library boundaries, files, and modified network services instead of ranking licenses by vague strength or business friendliness. +The [current graph](./TREE_STRUCTURE.md) separates software, fonts, and unsupported assets; checks authority independently; and handles existing project constraints before new-work preferences. The common MIT/Apache path takes six selections rather than a sequence of specialist yes/no questions. Advanced permissive choices retain the BSD/ISC/0BSD/Boost/Zlib/Unlicense distinctions. -GNU recommendations explicitly choose `-only` or `-or-later`; older GPL, LGPL, and EPL versions require an existing-project reason. Boost and Zlib add meaningful binary-notice/origin distinctions. Every real recommendation carries obligations and an SPDX link, while preferences outside the supported model produce guidance without a fake license link. +Copyleft choices distinguish combined programs, library boundaries, and files. AGPL's network source offer is an explicit goal, not a consequence of merely running a server. GNU grants explicitly choose `-only` or `-or-later`; legacy versions require a specific authorized reason. Fonts require accepting OFL-specific conditions. Every recommendation carries a rationale, obligations, and an SPDX link; unsupported goals produce typed guidance without a fake license link. Both CLI modes use `src/licenses.zig`. Compatibility results preserve original component obligations and distinguish conditional library/file/font/module combinations from unconditional permission to relicense. The supported catalog and limitations are documented in the [README](./README.md). diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 2d8d125..df72b21 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -15,7 +15,7 @@ git switch -c fix/describe-your-change zig build ``` -The project uses only the Zig standard library. There are no additional packages to install. +The build uses only the Zig standard library. [Gum](https://github.com/charmbracelet/gum) is an optional runtime executable for terminal menus, never a required package or automatic download. Use `--plain` to exercise the standalone interface. ## Run the CLI @@ -41,7 +41,7 @@ zig fmt --check src/ To format changed Zig files, use `zig fmt path/to/file.zig`. -Also exercise the affected CLI mode. For navigation, try `yes`, `no`, `back`, `reset`, and `quit`, including at a recommendation. For compatibility, check `GPL-2.0-only, Apache-2.0` (no combined-work target), `GPL-2.0-or-later, Apache-2.0` (GPL version 3 available), and `MIT, OFL-1.1` (conditional separate font assets). Reverse the input order and check that candidate statuses remain unchanged. See the [test plan](./TEST_PLAN.md) for additional boundaries. +Also exercise the affected CLI mode. For navigation, try numeric and label choices, `back`, `reset`, and `quit`, including at a recommendation and an advisory. Check a real Gum terminal session, cancellation, and a terminal without Gum; redirected output must contain no ANSI controls. For compatibility, check `GPL-2.0-only, Apache-2.0` (no combined-work target), `GPL-2.0-or-later, Apache-2.0` (GPL version 3 available), and `MIT, OFL-1.1` (conditional separate font assets). Reverse inputs and check statuses remain unchanged. See the [test plan](./TEST_PLAN.md). CI builds and tests pull requests on Linux, macOS, and Windows. Formatting is checked separately on Linux. @@ -52,11 +52,11 @@ CI installs the compiler through `.github/actions/setup-zig/action.yml`, verifie | File | Responsibility | | --- | --- | | `src/main.zig` | Process setup, input handling, and the two CLI modes | -| `src/tree.zig` | License-selection decision tree | +| `src/tree.zig` | Static intent-based menu graph and typed recommendation/advisory outcomes | | `src/licenses.zig` | Shared canonical SPDX identifiers, categories, obligations, and license-text URLs | | `src/compatibility.zig` | Directed combined-work assessments, conditional prerequisites, and common-target checks | -| `src/ui.zig` | ANSI terminal-screen rendering | -| `src/*_test.zig` | Decision-tree, compatibility, and rendering tests | +| `src/ui.zig` | Optional Gum subprocess menus, plain prompts, and selection parsing | +| `src/*_test.zig` | Licensing graph, compatibility, and input-boundary tests | | `build.zig` | Build, run, and test steps | Keep changes focused, follow existing Zig conventions, and let `zig fmt` handle indentation. Comments should explain non-obvious decisions rather than repeat the code. @@ -64,7 +64,7 @@ Keep changes focused, follow existing Zig conventions, and let `zig fmt` handle ## Adding or correcting licenses 1. Add a canonical SPDX tag and its category/obligations in `src/licenses.zig`. Parsing, display, SPDX links, and the CLI's supported list derive from this shared catalog; do not duplicate them. -2. Add a concrete obligation-driven path in `src/tree.zig`. Actual recommendations use typed `Node.license` metadata; advisory results leave it null. Explain distribution/network triggers, version permission, and any legacy-project requirement rather than ranking licenses as "simplest" or "business-friendly." +2. Add an intent- and obligation-driven path in `src/tree.zig`. `node(Id)` returns static choices with explicit destinations. Recommendations use typed `Node.license`; advisory outcomes use `Node.advisory`. Keep the common path short, preserve existing-license constraints, explain GNU grant permissions, and avoid ranking licenses as "simplest" or "business-friendly." 3. Update directed `assess()` rules in `src/compatibility.zig`. Distinguish ordinary compliance, specific conditional prerequisites, and actual combined-work conflicts. The target must satisfy **all** input licenses; original components keep their obligations. A symmetric pair result is a common-target search, not a directional relicensing verdict. 4. Add behavioral regressions in `src/tree_test.zig` / `src/compatibility_test.zig`: preference-to-license paths, permission/version boundaries, conditional combinations, and input-order independence. Avoid wording assertions, copied-metadata tests, or enum-membership checks without consumer behavior. 5. Update the [README](./README.md), [tree guide](./TREE_STRUCTURE.md), and [test plan](./TEST_PLAN.md) when affected. Include authoritative license sections in the pull request; exceptions, secondary-license notices, file/library boundaries, and GNU `-only` / `-or-later` permissions cannot be inferred from a short license name alone. diff --git a/README.md b/README.md index e436475..cca0c50 100644 --- a/README.md +++ b/README.md @@ -36,10 +36,10 @@ A terminal companion to [repolicense](https://github.com/lucasfth/repolicense), built with Zig and its standard library. Answer a series of questions to explore licenses for your project, or enter existing licenses to check the compatibility rules for combining code. -- **License selection** — separate font/software paths, concrete obligations, and explained recommendations. -- **Navigation** — go back to an earlier question or reset the session. +- **License selection** — intent-based software/font menus, ownership checks, and explained recommendations. +- **Navigation** — go back to an earlier question or restart the session. - **Compatibility checks** — supported and conditional combined-work targets, with explicit GNU versions. -- **Local operation** — no account, service, or runtime dependency; the CLI provides links rather than making API calls. +- **Local operation** — no account, service, or required runtime dependency. Optional [Gum](https://github.com/charmbracelet/gum) adds terminal menus; the CLI never downloads it or makes API calls. > This tool offers general guidance, not legal advice. Review the actual license terms and your project's obligations before choosing a license or combining code. @@ -86,20 +86,36 @@ Ensure `$HOME/.local/bin` is on your shell's `PATH`. Repeat the build command af ### Choose a license -Start without arguments. Fonts are routed first; software questions then cover licensing rights, patent grants, notices, and the scope of copyleft. Older GNU/EPL versions are offered for existing-project requirements, not as arbitrary fallbacks. Every recommendation includes its obligations and a link to the SPDX license text. +Start without arguments to open a menu: **Choose a license**, **Check compatibility**, or **Quit**. Select Choose a license, then software, font, or another asset, confirm licensing authority, and establish whether existing project terms constrain the choice. Existing/forked/community projects receive guidance to preserve their grants rather than a fresh license recommendation. Other assets get an explicit out-of-scope result. -You can revisit answers at any point, including after reaching a recommendation. A preference outside the supported choices produces guidance rather than a misleading license recommendation. +For new software, select broad open-source permissions and then permissive or copyleft goals. The common MIT/Apache path takes six selections. Specialist permissive licenses remain under advanced choices. Copyleft offers file, library, whole-program, and Eclipse-specific choices; only whole-program sharing asks about the additional AGPL network source offer. GNU version grants are explicit. Fonts require accepting OFL's same-license, notice, naming, and standalone-sale conditions. -| Input | Action | +Every recommendation explains why it fits, its obligations and limitations, and links to the SPDX text. Back/restart remain available at recommendations and advisory outcomes. + +### Optional terminal menus + +Install Gum separately if desired, for example `brew install gum` on macOS or `winget install charmbracelet.gum` on Windows. See [Gum installation](https://github.com/charmbracelet/gum#installation) for other platforms. It is not a build requirement. + +When `gum` is on `PATH`, all three standard streams are terminals, and `TERM` is not `dumb`, the CLI uses a styled header, rounded question/guidance/result panels, and highlighted arrow-key menus. Enter confirms a choice. Compatibility mode uses a styled introduction/results heading and searchable multi-select picker: type to search, Tab toggles and advances, Enter checks the selection, and Ctrl-C cancels. Gum renders its own menus; Repolicense replaces the viewport between questionnaire steps rather than guessing frame line counts. Styling and terminal behavior were exercised with Gum 2.0.2. + +Use `repolicense --plain` or `repolicense --compat --plain` to force plain mode. Missing Gum falls back automatically; redirected streams always use plain output without ANSI escapes. Gum's own exit status 1 can mean either cancellation or a runtime error: its diagnostics remain visible and no recommendation is inferred from that exit. + +Interactive questionnaire navigation replaces the previous view, including in numbered mode without Gum. Back and Restart clear the old question rather than adding another copy below it. Redirected streams and `TERM=dumb` retain a readable transcript instead. In Gum menus, Back is an explicit action; Esc/Ctrl-C cancels the session rather than selecting Back. + +| Plain input | Action | | --- | --- | -| `yes` or `y` | Answer yes | -| `no` or `n` | Answer no | +| Number or displayed label | Select an option | +| `y` / `n` | Select a literal Yes / No option, where offered | | `back` or `b` | Return to the previous question | -| `reset` or `r` | Start again | +| `reset`, `restart`, or `r` | Start again | | `quit`, `q`, or `exit` | End the session | +`--help` / `-h` shows usage. Unknown arguments exit with status 2. + ### Check compatibility +Choose **Check compatibility** from the start menu, or use `--compat` / `-c` to skip it. Gum results open a compact overview: selected identifiers, whether supported common targets exist, and ordinary/conditional target counts. Ordinary targets still require standard license compliance; conditional targets have additional prerequisites. **View targets and conditions** opens the full scrollable assessment, including pairwise checks; `q` returns to the overview. **Check another combination** reopens selection; **Quit** exits. Plain mode prints the full assessment directly. + ```sh zig build run -- --compat ``` @@ -152,7 +168,7 @@ zig build test zig fmt --check src/ ``` -The tests cover every recommendation/advisory path, graph navigation invariants, canonical license parsing, version/obligation boundaries, order-independent compatibility, and terminal-screen rendering. Exercise the actual CLI as described in the [test plan](./TEST_PLAN.md), not just the unit suite. [CI](https://github.com/lucasfth/repolicense-cli/actions/workflows/ci.yml) builds and tests on Linux, macOS, and Windows using Zig 0.17.0, with a separate formatting check. +The tests cover recommendation reachability, scope and authority boundaries, explicit version grants, canonical license parsing, order-independent compatibility, and menu input boundaries. Exercise the actual CLI with and without Gum as described in the [test plan](./TEST_PLAN.md), not just the unit suite. [CI](https://github.com/lucasfth/repolicense-cli/actions/workflows/ci.yml) builds and tests on Linux, macOS, and Windows using Zig 0.17.0, with a separate formatting check. For the decision-tree layout and project background, see [Tree structure](./TREE_STRUCTURE.md) and [Web/CLI comparison](./COMPARISON.md). diff --git a/TEST_PLAN.md b/TEST_PLAN.md index c43a6fd..787c098 100644 --- a/TEST_PLAN.md +++ b/TEST_PLAN.md @@ -10,26 +10,35 @@ zig fmt --check src/ ## Automated behavior coverage -- `src/tree_test.zig`: concrete preference paths for all 23 canonical recommendations and four advisory outcomes; complete question branches, terminal leaves, recommendation reachability, and cycle detection. +- `src/tree_test.zig`: all 23 recommendation outcomes reachable, graph cycles and terminal-leaf invariants, distinct authority/scope/existing/font outcomes, common-path length, copyleft boundaries, and explicit GNU grants. - `src/compatibility_test.zig`: canonical/case-insensitive parsing, rejection of ambiguous GNU IDs and compound expressions, GNU version and conversion boundaries, patent conflicts, conditional file/library/font treatment, all-input candidate intersection, and input-order invariance. -- `src/ui_test.zig`: terminal block replacement/clearing behavior. +- `src/ui_test.zig`: numbered/label selection, navigation aliases, conditional yes/no aliases, multi-selection deduplication, and invalid input/buffer boundaries. Keep tests about consumer-visible outcomes and invariants. Do not assert question wording, copied descriptions, enum existence, or incidental formatting. Add a failing regression before fixing a boundary; a successful compile is not proof of interactive behavior. -## Decision-tree smoke +## Questionnaire smoke -Run `./zig-out/bin/repolicense` (use `.exe` on Windows). Follow the [tree diagram](./TREE_STRUCTURE.md), checking: +Run `./zig-out/bin/repolicense --plain` (use `.exe` on Windows). Follow the [graph](./TREE_STRUCTURE.md), checking: -- The first font path reaches OFL without traversing software preferences. -- Software ownership/reserved-rights answers produce **guidance**, not a fake license or license-text link. -- The patent-grant branch reaches Apache before the other permissive choices. -- Source-only notice preservation routes to Boost or Zlib according to altered-source/origin requirements; retaining binary-copy notices routes to MIT/BSD/ISC. -- Project/library/file copyleft reaches GPL/LGPL/MPL respectively; AGPL explicitly covers modified network versions. -- GPL-2.0, LGPL-2.1, and EPL-1.0 require an existing-project reason; GNU leaves explicitly select `-only` or `-or-later`. -- Every real recommendation shows its canonical ID, meaningful obligations, and an SPDX license-text URL. -- Unmatched copyleft preferences produce guidance rather than an unrelated fallback. +- Other assets exit to out-of-scope guidance; uncertain software/font authority exits to ownership guidance. +- Existing/forked/community projects preserve existing terms rather than receiving a replacement license. +- New software reaches MIT or Apache after six selections. Advanced permissives remain available with their distinct conditions. +- File/library/whole-program copyleft reaches MPL/LGPL/GPL. The whole-program network source-offer preference reaches AGPL; a server alone does not imply AGPL. +- Legacy GNU/EPL choices require a specific authorized reason; GNU grants explicitly select `-only` or `-or-later`. +- Font conditions are explicitly accepted or rejected; help explains them before accepting/refusing. +- Recommendations display canonical ID, rationale, obligations/limitations, and SPDX URL. Advisory outcomes have no license link. +- Numbers and labels work; invalid/blank input retries; back/restart/quit work before and after results. Back at the root stays there. EOF exits cleanly at root, in a questionnaire, and at a result. +- `--help` shows usage; an unknown argument exits 2. Redirected output contains no ANSI escapes. -Verify navigation before and after a result: `back`, `reset`, uppercase/short answers, invalid input, and `quit`. Back at the root should stay at the root. Advisory leaves must support the same navigation as recommendations. +For a reproducible common-path smoke, pipe seven `1` lines followed by `quit` to `repolicense --plain`: one chooses the questionnaire from the start menu, then six reach MIT. Expect `License: MIT` and exit 0. Also select Check compatibility from the start menu and submit a canonical SPDX list, ensuring buffered input survives the mode transition. `--compat` / `-c` must bypass the launcher. Do not use old yes/no scripts: menus are multi-choice. + +## Gum terminal smoke + +Install Gum separately and run the real CLI in a terminal with `TERM` not `dumb` (Gum 2.0.2 has been exercised). Verify the colored header, rounded question and result panels, highlighted cursor, arrow-key/Enter selection, and complete MIT path. Exercise Software → Ownership → Back → Restart: the visible screen must contain only the current question. Exercise Font → ownership → new font → reject: the visible screen must show the OFL-mismatch advisory, not a previous question. Check a narrow terminal with `COLUMNS` set and the longest result. Esc/Ctrl-C cancels without a recommendation inferred from cancellation. Gum renders the menus; the parent replaces views without counting Gum frame lines. + +Run `--compat` at 80×24, select MIT plus Apache-2.0, and submit. The compact overview must show selected IDs, common-target outcome, ordinary/conditional counts, and all three actions without scrolling. View targets and conditions must open a scrollable full assessment; `q` returns to the overview. Check another combination explicitly reopens selection; Quit exits. Also check GPL-2.0-only + Apache-2.0 (no supported target) and MIT + OFL-1.1 (conditional-only), without implying blanket relicensing. Tab toggles and advances in the picker; Ctrl-C cancels. Gum version 2 exits 1 for both Esc and some runtime errors, so retain diagnostics and never infer a negative answer. + +Repeat with Gum absent from `PATH` and with `--plain` even while Gum is present: numbered questionnaire prompts must replace the terminal viewport on forward, Back, and Restart transitions. Only the current question should remain visible, including when long text wraps. Redirected streams and `TERM=dumb` must instead retain plain transcripts without controls. The missing-Gum compatibility fallback accepts canonical SPDX lists or numbered options on its first picker; subsequent inputs use canonical lists. ## Compatibility smoke @@ -68,4 +77,4 @@ repolicense repolicense --compat ``` -Ensure `$HOME/.local/bin` is on `PATH` or that your configured shell alias resolves to the current build. CI compiles and runs tests on Linux, macOS, and Windows; also inspect interactive clearing in a real terminal when changing `src/ui.zig`. +Ensure `$HOME/.local/bin` is on `PATH` or that your configured shell alias resolves to the current build. CI compiles and runs tests on Linux, macOS, and Windows. Native terminal behavior still needs the real Gum/plain smoke checks above; cross-compilation alone does not prove it. diff --git a/TREE_STRUCTURE.md b/TREE_STRUCTURE.md index 10a403a..08cef8c 100644 --- a/TREE_STRUCTURE.md +++ b/TREE_STRUCTURE.md @@ -1,100 +1,75 @@ -# Decision tree +# Licensing questionnaire -The CLI's tree is obligation-driven rather than a ranking of licenses from “simple” to “strong.” Its recommendations and `--compat` share the canonical SPDX catalog in `src/licenses.zig`. All 23 licenses and four advisory outcomes have explicit preference-path coverage in `src/tree_test.zig`. +The CLI uses a static multi-choice graph in `src/tree.zig`. It asks about the work, licensing authority, existing constraints, and intended obligations before recommending terms. `src/licenses.zig` supplies all 23 canonical identifiers and their obligations to both recommendation and compatibility modes. -## Entry and scope +## Entry and constraints ```mermaid flowchart TD - Font{Licensing a font?} - Font -->|Yes| FontRights{Allow use, embedding, modification, and redistribution?} - FontRights -->|Yes| OFL[OFL-1.1] - FontRights -->|No| FontAdvice[No matching font recommendation] - Font -->|No| Ownership{Software you have the right to license?} - Ownership -->|No| OwnershipAdvice[Resolve ownership and existing obligations] - Ownership -->|Yes| Rights{Allow use, modification, redistribution, and commercial use?} - Rights -->|No| RightsAdvice[Open-source terms do not fit reserved rights] - Rights -->|Yes| Sharing{Source-sharing for distribution or modified network services?} - Sharing -->|No| Permissive[Permissive path] - Sharing -->|Yes| Copyleft[Copyleft path] + Type{What are you licensing?} + Type -->|Other asset| Scope[Outside this questionnaire] + Type -->|Software| Rights{Licensing authority?} + Type -->|Font| FontRights{Font licensing authority?} + Rights -->|Uncertain| Ownership[Resolve ownership and permission] + FontRights -->|Uncertain| Ownership + Rights -->|Confirmed| Existing{New or existing project?} + FontRights -->|Confirmed| FontExisting{New or existing font?} + Existing -->|Existing, fork, community| Terms[Preserve existing grants; review compatibility] + FontExisting -->|Existing, fork, community| FontTerms[Preserve existing font terms and names] + Existing -->|New| Permissions{Broad open-source permissions fit?} + Permissions -->|No| Reserved[Review proprietary or specialized terms] + Permissions -->|Yes or informed help choice| Model{Permissive or copyleft?} + FontExisting -->|New| OFLTerms{Accept OFL conditions?} + OFLTerms -->|Yes| OFL[OFL-1.1] + OFLTerms -->|No| FontMismatch[No matching font recommendation] ``` -OFL fonts keep their own terms, including Reserved Font Names and the restriction on selling fonts by themselves. Documents made with those fonts do not inherit OFL. The software path assumes you have licensing rights; existing component licenses cannot be replaced just by choosing a new project license. Documentation and other non-font assets are outside this tree. +Ownership is not inferred from asset type. Existing-project guidance is deliberately not a license replacement operation: copyright owners, contributor authority, original notices, existing grants, and dependency compatibility must be considered first. Documentation, images, and datasets have an explicit out-of-scope outcome rather than a misleading software-ownership warning. -## Permissive path +The font path requires accepting OFL redistribution under the same license, notices, Reserved Font Name rules, and the restriction on selling fonts by themselves. Documents created with the fonts do not inherit OFL. Its help menu explains the conditions before offering accept/refuse again. -```mermaid -flowchart TD - Patents{Express contributor patent license?} - Patents -->|Yes| Apache[Apache-2.0] - Patents -->|No| Dedication{Public-domain dedication?} - Dedication -->|Yes| Unlicense[Unlicense] - Dedication -->|No| Notices{Waive notice-copy requirements?} - Notices -->|Yes| Zero[0BSD] - Notices -->|No| Binary{Source notices enough; no binary notice-copy requirement?} - Binary -->|Yes| Marking{Require origin integrity and marked altered source?} - Marking -->|Yes| Zlib[Zlib] - Marking -->|No| Boost[BSL-1.0] - Binary -->|No| Endorsement{Explicit no-endorsement clause?} - Endorsement -->|Yes| BSD3[BSD-3-Clause] - Endorsement -->|No| BSDPreference{BSD-2-Clause ecosystem preference?} - BSDPreference -->|Yes| BSD2[BSD-2-Clause] - BSDPreference -->|No| ISCPreference{ISC ecosystem preference?} - ISCPreference -->|Yes| ISC[ISC] - ISCPreference -->|No| MIT[MIT] -``` +## New software choices -The patent question precedes all permissive recommendations. Apache adds express patent grants, change notices, and applicable NOTICE attributions. MIT, BSD, and ISC preserve notices rather than eliminating conditions. `0BSD` retains copyright without a notice-copy requirement; `Unlicense` attempts a public-domain dedication. Boost's binary exemption differs from Zlib's altered-source/origin rules. `BSL-1.0` means **Boost**, not the Business Source License. +The common path takes six selections: software, authority, new work, open-source permissions, permissive model, then MIT or Apache-2.0. These common choices are not a ranking of every license by legal strength. Advanced permissive choices expose BSD-2-Clause, BSD-3-Clause, ISC, 0BSD, Boost (`BSL-1.0`, not Business Source License), Zlib, and Unlicense with their distinguishing terms. -## Copyleft path +MIT requires retaining notices; Apache adds an express contributor patent grant, change notices, and applicable NOTICE attributions. 0BSD waives notice-copy requirements without dedicating copyright. Unlicense attempts a public-domain dedication with a fallback permission. Boost exempts solely machine-executable distributions from its notice condition; Zlib adds origin integrity and altered-source marking rules. BSD-3-Clause adds an express no-endorsement clause. + +The copyleft scope menu makes the architectural distinction explicit: ```mermaid flowchart TD - Network{Source offer to remote users of modified network versions?} - Network -->|Yes| AGPLLater{Permit later AGPL versions?} - AGPLLater -->|Yes| AGPLLaterLeaf[AGPL-3.0-or-later] - AGPLLater -->|No| AGPLOnly[AGPL-3.0-only] - Network -->|No| Program{Copyleft covers combined program?} - Program -->|Yes| GPLLegacy{Existing project requires GPL version 2?} - GPLLegacy -->|Yes| GPL2Later{Permit later GPL versions?} - GPL2Later -->|Yes| GPL2LaterLeaf[GPL-2.0-or-later] - GPL2Later -->|No| GPL2Only[GPL-2.0-only] - GPLLegacy -->|No| GPL3Later{Permit later GPL versions?} - GPL3Later -->|Yes| GPL3LaterLeaf[GPL-3.0-or-later] - GPL3Later -->|No| GPL3Only[GPL-3.0-only] - Program -->|No| Library{Library boundary with relinking rights?} - Library -->|Yes| LGPLLegacy{Existing library requires LGPL version 2.1?} - LGPLLegacy -->|Yes| LGPL21Later{Permit later LGPL versions?} - LGPL21Later -->|Yes| LGPL21LaterLeaf[LGPL-2.1-or-later] - LGPL21Later -->|No| LGPL21Only[LGPL-2.1-only] - LGPLLegacy -->|No| LGPL3Later{Permit later LGPL versions?} - LGPL3Later -->|Yes| LGPL3LaterLeaf[LGPL-3.0-or-later] - LGPL3Later -->|No| LGPL3Only[LGPL-3.0-only] - Library -->|No| Files{File-level copyleft?} - Files -->|Yes| MPL[MPL-2.0] - Files -->|No| Eclipse{Eclipse ecosystem requirement?} - Eclipse -->|Yes| EPLLegacy{Existing project requires EPL version 1.0?} - EPLLegacy -->|Yes| EPL1[EPL-1.0] - EPLLegacy -->|No| EPL2[EPL-2.0] - Eclipse -->|No| Advice[No matching copyleft recommendation] + Scope{Where should copyleft apply?} + Scope -->|Covered files| MPL[MPL-2.0] + Scope -->|Library boundary| LGPL{LGPL version grant} + LGPL --> LGPL3[LGPL-3.0-only or LGPL-3.0-or-later] + LGPL -->|Specific authorized legacy reason| LGPL21[LGPL-2.1-only or LGPL-2.1-or-later] + Scope -->|Combined program| Network{Want source offers for remote users of modified versions?} + Network -->|Yes| AGPL[AGPL-3.0-only or AGPL-3.0-or-later] + Network -->|No| GPL{GPL version grant} + GPL --> GPL3[GPL-3.0-only or GPL-3.0-or-later] + GPL -->|Specific authorized legacy reason| GPL2[GPL-2.0-only or GPL-2.0-or-later] + Scope -->|Eclipse-specific fit| EPL[EPL-2.0; EPL-1.0 only for authorized legacy requirements] ``` -GPL covers covered combined programs, LGPL provides library-level permissions with source/relinking conditions, and MPL keeps covered files under its terms while separate files may differ. EPL is selected for an explicit ecosystem requirement, not vague “business-friendly” preferences. +GNU later-version permission is always explicit, never inferred from a bare identifier or existing version. Legacy options on the new-work path are for an authorized compatibility/project reason; they do not upgrade or replace third-party grants. LGPL-2.1's GPL conversion option is distinct from upgrading an LGPL-2.1-only grant to LGPL-3.0. + +Ordinary copyleft concerns covered distributions, not publication of every private modification. GPL covers combined programs, LGPL provides library-boundary permissions subject to source/relinking conditions, and MPL covers files while separate files may differ. Merely operating a server is not a reason to force AGPL: the questionnaire asks whether its additional network source-offer obligation is intended. EPL has its own contribution, patent, and commercial-distributor obligations; it is not a generic business-friendly fallback. -Ordinary copyleft distribution obligations do not require publishing every private modification. AGPL adds a source offer to remote users of a modified network version. Existing projects must already grant any selected later-version permission, or you must own the rights to grant it. LGPL-2.1's GPL conversion option is not permission to upgrade an LGPL-2.1-only library to LGPL-3.0. +Help choices explain the model and offer informed choices rather than interpreting uncertainty as a negative answer. Every recommendation shows a rationale, obligations/limitations, and the SPDX text link. Typed advisory outcomes have no fake license link. Back/restart/quit work at all outcomes. -## Compatibility is a separate assessment +## Presentation and compatibility -A recommendation describes suitable terms for work you can license. `--compat` instead considers **all existing licenses** and distinguishes ordinary compliance from conditions requiring separate libraries/files, font bundling, GNU module combinations, or secondary-license grants. Pairwise checks search for a common target; they are not symmetric relicensing permissions. +Optional Gum owns each menu's frames. The questionnaire clears the terminal viewport before rendering the next question, recommendation, or advisory, including Back and Restart. Numbered prompts without Gum use the same replacing view. This avoids guessing line counts for wrapped text or Gum frames. Redirected streams and `TERM=dumb` instead retain an append-only transcript without terminal controls. Both presentations consume the same graph; recommendations do not depend on whether Gum is installed. See the [README](./README.md#optional-terminal-menus) for installation and controls. -In particular, GPL-2.0-only and GPL-3.0 cannot be freely combined, while GPL-2.0-or-later permits selecting version 3. GPL/AGPL version-3 combinations retain separate module licenses under section 13. MPL secondary licensing depends on eligibility and an actual larger work with secondary-licensed material. EPL secondary licensing needs the initial contributor's specific Exhibit A notice; including the template license text does not supply that grant. +`--compat` remains a separate assessment of existing license combinations, not a fresh recommendation or a promise that components can be relicensed. It considers all inputs, preserves original obligations, and explains conditional file/library/font/module boundaries. Plain mode accepts canonical SPDX lists; Gum provides searchable multi-selection. Compound expressions, exceptions, unlisted versions, jurisdiction-specific issues, and actual source-combination facts remain outside the model. ## Authoritative references -- [GNU license compatibility list](https://www.gnu.org/licenses/license-list.html) and [GNU compatibility matrix](https://www.gnu.org/licenses/gpl-faq.html#AllCompatibility). -- [LGPL-2.1 section 3: GPL conversion](https://www.gnu.org/licenses/old-licenses/lgpl-2.1.html) and [GPL-3.0 section 13: AGPL combinations](https://spdx.org/licenses/GPL-3.0-only.html). -- [MPL-2.0 section 3.3](https://spdx.org/licenses/MPL-2.0.html) and [Mozilla's MPL FAQ](https://www.mozilla.org/en-US/MPL/2.0/FAQ/). -- [EPL-2.0 section 3.2 and Exhibit A](https://spdx.org/licenses/EPL-2.0.html). -- [Boost Software License](https://www.boost.org/LICENSE_1_0.txt), [Zlib](https://spdx.org/licenses/Zlib.html), and [official OFL text](https://openfontlicense.org/open-font-license-official-text/). +- [GNU license selection guidance](https://www.gnu.org/licenses/license-recommendations.html), [license list](https://www.gnu.org/licenses/license-list.html), and [compatibility FAQ](https://www.gnu.org/licenses/gpl-faq.html#AllCompatibility). +- [Existing project and community guidance](https://choosealicense.com/community/). +- [Apache-2.0 text](https://www.apache.org/licenses/LICENSE-2.0.txt). +- [MPL FAQ](https://www.mozilla.org/en-US/MPL/2.0/FAQ/) and [MPL-2.0 section 3.3](https://spdx.org/licenses/MPL-2.0.html). +- [LGPL-2.1 section 3](https://www.gnu.org/licenses/old-licenses/lgpl-2.1.html), [GPL-3.0 section 13](https://spdx.org/licenses/GPL-3.0-only.html), and [EPL-2.0 section 3.2 / Exhibit A](https://spdx.org/licenses/EPL-2.0.html). +- [Boost](https://www.boost.org/LICENSE_1_0.txt), [Zlib](https://spdx.org/licenses/Zlib.html), and [official OFL text](https://openfontlicense.org/open-font-license-official-text/). -This is general guidance, not legal advice. Review the applicable license texts, notices, and how your code is combined. +This is general guidance, not legal advice. Review actual license texts, grants, notices, and how code is combined. diff --git a/install.sh b/install.sh index 665ede1..f1b02b9 100755 --- a/install.sh +++ b/install.sh @@ -39,3 +39,6 @@ echo " ./zig-out/bin/repolicense" echo "" echo "Or:" echo " zig build run" +echo "" +echo "Optional: install Gum separately for arrow-key menus (https://github.com/charmbracelet/gum)." +echo "Gum is not required. Use ./zig-out/bin/repolicense --plain for numbered prompts." diff --git a/src/main.zig b/src/main.zig index d83252d..6d25183 100644 --- a/src/main.zig +++ b/src/main.zig @@ -1,45 +1,63 @@ const std = @import("std"); const mem = std.mem; const tree = @import("tree.zig"); +const ui = @import("ui.zig"); const compatibility = @import("compatibility.zig"); const catalog = @import("licenses.zig"); -const Node = tree.Node; -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(init: std.process.Init) !void { - const allocator = init.gpa; - - var stdout_buffer: [1024]u8 = undefined; + var stdout_buffer: [4096]u8 = undefined; var stdout_file = std.Io.File.stdout().writer(init.io, &stdout_buffer); const stdout = &stdout_file.interface; - - var args = try init.minimal.args.iterateAllocator(allocator); + var args = try init.minimal.args.iterateAllocator(init.gpa); defer args.deinit(); _ = args.next(); - - // Check if compatibility mode is requested via flag (`--compat` or `-c`) - var is_compat: bool = false; + var is_compat = false; + var plain = false; + var help = false; while (args.next()) |arg| { if (mem.eql(u8, arg, "--compat") or mem.eql(u8, arg, "-c")) { is_compat = true; - break; + } else if (mem.eql(u8, arg, "--plain")) { + plain = true; + } else if (mem.eql(u8, arg, "--help") or mem.eql(u8, arg, "-h")) { + help = true; + } else { + try stdout.print("Unknown argument: {s}\nUse repolicense --help for usage.\n", .{arg}); + try stdout.flush(); + std.process.exit(2); } } - - if (is_compat) { - try runCompatibilityMode(init.io, stdout); + if (help) { + try stdout.writeAll( + \\Usage: repolicense [--compat | -c] [--plain] + \\ repolicense --help + \\ + \\Choose a license for work you can license, or assess existing license combinations. + \\Start without flags to choose license guidance or compatibility checking. + \\Gum menus are optional and used only in an interactive terminal when gum is on PATH. + \\--plain disables Gum. Redirected input/output always uses plain text. + \\Plain menus accept a number or label; back/b, reset/r, and quit/q remain available. + \\Plain --compat accepts comma-separated canonical SPDX identifiers. + \\This tool provides general guidance, not legal advice. + \\ + ); } else { - // Run normal decision tree mode - try runDecisionTree(allocator, init.io, stdout); + var prompts = ui.Prompter.init(init, plain); + var stdin_buffer: [4096]u8 = undefined; + var stdin_file = std.Io.File.stdin().reader(init.io, &stdin_buffer); + const stdin = &stdin_file.interface; + if (is_compat) { + try runCompatibilityMode(stdin, stdout, &prompts); + } else { + try prompts.renderView(stdout, "WELCOME", "What would you like to do?", "Choose a license for your own work, or check combinations of existing licenses. You can also open the checker directly with --compat."); + const mode = try prompts.choose(stdout, stdin, &.{ "Choose a license", "Check compatibility", "Quit" }); + if (mode) |selected| switch (selected) { + 0 => try runDecisionTree(init.gpa, stdin, stdout, &prompts), + 1 => try runCompatibilityMode(stdin, stdout, &prompts), + else => try stdout.writeAll("\nThank you for using Repolicense\n"), + }; + } } try stdout.flush(); } @@ -49,195 +67,191 @@ fn readLine(stdin: *std.Io.Reader, stdout: *std.Io.Writer) !?[]u8 { return stdin.takeDelimiter('\n'); } -fn runCompatibilityMode(io: std.Io, stdout: anytype) !void { - var buf: [1024]u8 = undefined; - var stdin_file = std.Io.File.stdin().reader(io, &buf); - const stdin = &stdin_file.interface; - - try stdout.print("\n=== {s}License Compatibility Checker{s} ===\n", .{ BOLD, RESET }); - try stdout.print("Check licenses for a covered combined work, not merely separate projects in one repository.\n", .{}); - try stdout.print("Original notices and component obligations remain. Conditional results require the stated conditions.\n", .{}); - try stdout.print("Exceptions, dual-license expressions, jurisdiction, and unlisted versions are not modeled; this is not legal advice.\n\n", .{}); - try stdout.print("Enter comma-separated canonical SPDX identifiers, for example: MIT, Apache-2.0\n", .{}); - try stdout.print("GNU identifiers must specify '-only' or '-or-later'.\n\nSupported licenses:\n", .{}); - for ([_]catalog.Category{ .Permissive, .StrongCopyleft, .WeakCopyleft, .PublicDomain, .Font }) |category| { - try stdout.print(" {s}: ", .{@tagName(category)}); - var first = true; - for (catalog.all) |license| { - if (license.getCategory() != category) continue; - if (!first) try stdout.print(", ", .{}); - try stdout.print("{s}", .{license.toString()}); - first = false; +fn runCompatibilityMode(stdin: *std.Io.Reader, stdout: *std.Io.Writer, prompts: *ui.Prompter) !void { + try prompts.renderView(stdout, "COMPATIBILITY", "Check existing license combinations", "Assess a covered combined work, not merely separate projects in one repository. Original notices and component obligations remain. Conditional results require the stated conditions. Exceptions, compound expressions, jurisdiction, and unlisted versions are not modeled."); + if (!prompts.usingGum()) { + try stdout.writeAll("Enter comma-separated canonical SPDX identifiers, for example: MIT, Apache-2.0\nGNU identifiers must specify '-only' or '-or-later'.\n\nSupported licenses:\n"); + for ([_]catalog.Category{ .Permissive, .StrongCopyleft, .WeakCopyleft, .PublicDomain, .Font }) |category| { + try stdout.print(" {s}: ", .{@tagName(category)}); + var first = true; + for (catalog.all) |license| { + if (license.getCategory() != category) continue; + if (!first) try stdout.writeAll(", "); + try stdout.writeAll(license.toString()); + first = false; + } + try stdout.writeByte('\n'); } - try stdout.print("\n", .{}); } - try stdout.print("\nEnter licenses (or 'quit' to exit): ", .{}); - while (true) { - const line = (try readLine(stdin, stdout)) orelse break; - const trimmed = mem.trim(u8, line, &std.ascii.whitespace); - if (std.ascii.eqlIgnoreCase(trimmed, "quit") or std.ascii.eqlIgnoreCase(trimmed, "q")) { - try stdout.print("\nThank you for using the compatibility checker of Repolicense\n", .{}); - break; - } - if (trimmed.len == 0) { - try stdout.print("\nEnter licenses (or 'quit' to exit): ", .{}); - continue; - } - var selected: [catalog.all.len]catalog.License = undefined; var count: usize = 0; - var iter = 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; - const license = catalog.License.fromString(clean) orelse { - try stdout.print("\nError: Unknown or ambiguous license '{s}'. Use a supported canonical identifier; GNU licenses require '-only' or '-or-later'.\n", .{clean}); - has_error = true; - break; - }; - var duplicate = false; - for (selected[0..count]) |previous| { - if (previous == license) { - duplicate = true; + if (prompts.usingGum()) { + var labels: [catalog.all.len][]const u8 = undefined; + for (catalog.all, 0..) |license, i| labels[i] = license.toString(); + var indices: [catalog.all.len]usize = undefined; + try stdout.writeAll("\nSelect existing licenses (type to search; Tab toggles and advances; Enter checks; Ctrl-C cancels).\n"); + const chosen = (try prompts.pickMany(stdout, stdin, &labels, &indices)) orelse break; + for (chosen) |index| { + selected[count] = catalog.all[index]; + count += 1; + } + } else { + try stdout.writeAll("\nEnter licenses (or 'quit' to exit): "); + const line = (try readLine(stdin, stdout)) orelse break; + const trimmed = mem.trim(u8, line, &std.ascii.whitespace); + if (std.ascii.eqlIgnoreCase(trimmed, "quit") or std.ascii.eqlIgnoreCase(trimmed, "q") or std.ascii.eqlIgnoreCase(trimmed, "exit")) break; + if (trimmed.len == 0) continue; + var iter = mem.tokenizeAny(u8, trimmed, ","); + var has_error = false; + while (iter.next()) |license_str| { + const clean = mem.trim(u8, license_str, &std.ascii.whitespace); + const license = catalog.License.fromString(clean) orelse { + try stdout.print("\nError: Unknown or ambiguous license '{s}'. Use a supported canonical identifier; GNU licenses require '-only' or '-or-later'.\n", .{clean}); + has_error = true; break; + }; + var duplicate = false; + for (selected[0..count]) |previous| { + if (previous == license) { + duplicate = true; + break; + } + } + if (!duplicate) { + selected[count] = license; + count += 1; } } - if (!duplicate) { - selected[count] = license; - count += 1; - } + if (has_error) continue; } - if (has_error or count == 0) { - if (!has_error) try stdout.print("\nError: No valid licenses entered\n", .{}); - try stdout.print("\nEnter licenses (or 'quit' to exit): ", .{}); + if (count == 0) { + try stdout.writeAll("\nError: No valid licenses entered\n"); continue; } - - const input_licenses = selected[0..count]; - var candidates_buffer: [catalog.all.len]compatibility.Candidate = undefined; - const candidates = compatibility.findCandidates(input_licenses, &candidates_buffer); - - try stdout.print("\n--- {s}Results{s} ---\nGiven licenses: ", .{ BOLD, RESET }); - for (input_licenses, 0..) |license, i| { - if (i > 0) try stdout.print(", ", .{}); - try stdout.print("{s}", .{license.toString()}); - } - try stdout.print("\n\n", .{}); - if (candidates.len == 0) { - try stdout.print("No combined-work target found under these rules.\n", .{}); - try stdout.print("This does not rule out independent aggregation or separately granted permissions.\n\n", .{}); - } else { - try stdout.print("Combined-work targets; retain required notices and applicable component obligations:\n", .{}); + if (prompts.usingGum()) { + var candidates_buffer: [catalog.all.len]compatibility.Candidate = undefined; + const candidates = compatibility.findCandidates(selected[0..count], &candidates_buffer); + var ordinary: usize = 0; for (candidates) |candidate| { - try stdout.print(" [{s}] {s}\n", .{ @tagName(candidate.status), candidate.license.toString() }); - if (candidate.status == .conditional) { - for (input_licenses) |source| { - const assessment = compatibility.assess(source, candidate.license); - if (assessment.status == .conditional) { - try stdout.print(" {s}: {s}\n", .{ source.toString(), assessment.reason }); - } - } + if (candidate.status == .compatible) ordinary += 1; + } + var summary_buffer: [2048]u8 = undefined; + var summary = std.Io.Writer.fixed(&summary_buffer); + try summary.writeAll("Selected: "); + for (selected[0..count], 0..) |license, i| { + if (i > 0) try summary.writeAll(", "); + try summary.writeAll(license.toString()); + } + try summary.print("\n\n{d} ordinary targets {d} conditional targets\n\nOrdinary: standard obligations still apply.\nConditional: additional prerequisites must be checked.\nComponent licenses and notices remain in force.", .{ ordinary, candidates.len - ordinary }); + const title = if (ordinary > 0) "COMMON TARGETS AVAILABLE" else if (candidates.len > 0) "ONLY CONDITIONAL TARGETS FOUND" else "NO SUPPORTED COMMON TARGET"; + const color: []const u8 = if (ordinary > 0) "42" else if (candidates.len > 0) "214" else "196"; + var another = false; + while (true) { + try prompts.overview(stdout, title, summary.buffered(), color); + const action = (try prompts.choose(stdout, stdin, &.{ "View targets and conditions", "Check another combination", "Quit" })) orelse break; + if (action == 0) { + var details = std.Io.Writer.Allocating.init(prompts.process.gpa); + defer details.deinit(); + try printCompatibility(&details.writer, selected[0..count]); + try prompts.page(stdout, details.written()); + } else { + another = action == 1; + break; } } - try stdout.print("\n", .{}); + if (!another) break; + try prompts.renderView(stdout, "COMPATIBILITY", "Check another combination", "Select existing licenses. The next screen summarizes common targets; full conditions remain available in the detail viewer."); + } else { + try printCompatibility(stdout, selected[0..count]); } + } + try stdout.writeAll("\nThank you for using the compatibility checker of Repolicense\n"); +} - if (count > 1) { - try stdout.print("Pairwise common-target checks (not permission to relicense either component):\n", .{}); - for (input_licenses, 0..) |a, i| { - for (input_licenses[i + 1 ..]) |b| { - const assessment = compatibility.assessPair(a, b); - try stdout.print(" [{s}] {s} + {s}: {s}\n", .{ @tagName(assessment.status), a.toString(), b.toString(), assessment.reason }); +fn printCompatibility(stdout: *std.Io.Writer, input_licenses: []const catalog.License) !void { + var candidates_buffer: [catalog.all.len]compatibility.Candidate = undefined; + const candidates = compatibility.findCandidates(input_licenses, &candidates_buffer); + try stdout.writeAll("\n--- Results ---\nGiven licenses: "); + for (input_licenses, 0..) |license, i| { + if (i > 0) try stdout.writeAll(", "); + try stdout.writeAll(license.toString()); + } + try stdout.writeAll("\n\n"); + if (candidates.len == 0) { + try stdout.writeAll("No combined-work target found under these rules.\nThis does not rule out independent aggregation or separately granted permissions.\n\n"); + } else { + try stdout.writeAll("Combined-work targets; retain required notices and applicable component obligations:\n"); + for (candidates) |candidate| { + try stdout.print(" [{s}] {s}\n", .{ @tagName(candidate.status), candidate.license.toString() }); + if (candidate.status == .conditional) { + for (input_licenses) |source| { + const assessment = compatibility.assess(source, candidate.license); + if (assessment.status == .conditional) try stdout.print(" {s}: {s}\n", .{ source.toString(), assessment.reason }); } } - try stdout.print("\n", .{}); } - try stdout.print("Enter licenses (or 'quit' to exit): ", .{}); + try stdout.writeByte('\n'); + } + if (input_licenses.len > 1) { + try stdout.writeAll("Pairwise common-target checks (not permission to relicense either component):\n"); + for (input_licenses, 0..) |a, i| { + for (input_licenses[i + 1 ..]) |b| { + const assessment = compatibility.assessPair(a, b); + try stdout.print(" [{s}] {s} + {s}: {s}\n", .{ @tagName(assessment.status), a.toString(), b.toString(), assessment.reason }); + } + } + try stdout.writeByte('\n'); } } -fn runDecisionTree(allocator: std.mem.Allocator, io: std.Io, stdout: anytype) !void { - var buf: [256]u8 = undefined; - var stdin_file = std.Io.File.stdin().reader(io, &buf); - const stdin = &stdin_file.interface; - const ui = @import("ui.zig"); - - var screen = ui.Screen.init(); - - var history = try History.initCapacity(allocator, 0); +fn runDecisionTree(allocator: mem.Allocator, stdin: *std.Io.Reader, stdout: *std.Io.Writer, prompts: *ui.Prompter) !void { + var history = try std.ArrayList(tree.Id).initCapacity(allocator, 16); defer history.deinit(allocator); - - var current_node = &decision_tree; - try history.append(allocator, current_node); - - // 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 with 'yes', 'no', 'back', 'reset', or 'quit' to explore a suitable license.\n", .{}); - try stdout.print("Recommendations explain obligations; they are not legal advice or permission to replace third-party licenses.\n", .{}); - try stdout.print("\nRun with {s}'--compat'{s} (or {s}'-c'{s}) to check combinations of existing licenses.\n\n", .{ UNDERLINE, RESET, UNDERLINE, RESET }); - + try history.append(allocator, .work_type); while (true) { - if (current_node.node_type == .Question) { - const content = if (current_node.elaboration.len > 0) - try allocator.print("\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 allocator.print("\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); + const current = tree.node(history.items[history.items.len - 1]); + var content_buffer: [8192]u8 = undefined; + if (current.choices.len > 0) { + var heading_buffer: [48]u8 = undefined; + const heading = try std.fmt.bufPrint(&heading_buffer, "QUESTION {d}", .{history.items.len}); + try prompts.renderView(stdout, heading, current.title, current.detail); + } else if (current.license) |license| { + const content = try std.fmt.bufPrint(&content_buffer, "Why it fits: {s}\n\nObligations and limitations: {s}\n\nLicense text: {s}", .{ current.detail, license.description(), license.url() }); + var title_buffer: [64]u8 = undefined; + const title = try std.fmt.bufPrint(&title_buffer, "License: {s}", .{license.toString()}); + try prompts.renderView(stdout, "RECOMMENDATION", title, content); } else { - const content = if (current_node.license) |license| - try allocator.print("\n--- {s}RESULT{s} ---\nLicense: {s}{s}{s}\n\n{s}\n\nLicense text: {s}\n\nOptions: back/reset/quit: ", .{ BOLD, RESET, BOLD, license.toString(), RESET, current_node.elaboration, license.url() }) - else - try allocator.print("\n--- {s}GUIDANCE{s} ---\n{s}{s}{s}\n\n{s}\n\nOptions: back/reset/quit: ", .{ BOLD, RESET, BOLD, current_node.content, RESET, current_node.elaboration }); - try screen.render(stdout, content); - allocator.free(content); + try prompts.renderView(stdout, "GUIDANCE", current.title, current.detail); } - - const line = (try readLine(stdin, stdout)) orelse break; - const trimmed = mem.trim(u8, line, &std.ascii.whitespace); - const answer = trimmed; - - // Clear previous rendered block and the input line the user just entered - try screen.clearIncludingInput(stdout); - - if (std.ascii.eqlIgnoreCase(answer, "quit") or std.ascii.eqlIgnoreCase(answer, "q") or std.ascii.eqlIgnoreCase(answer, "exit")) { - // Clear last rendered block before exiting - try screen.clear(stdout); - try stdout.print("\nThank you for using Repolicense\n", .{}); - break; - } else if (std.ascii.eqlIgnoreCase(answer, "back") or std.ascii.eqlIgnoreCase(answer, "b")) { + var labels: [std.meta.tags(tree.Id).len + 3][]const u8 = undefined; + var label_buffer: [16384]u8 = undefined; + var used: usize = 0; + for (current.choices, 0..) |choice, i| { + if (choice.detail.len == 0) { + labels[i] = choice.label; + } else { + labels[i] = try std.fmt.bufPrint(label_buffer[used..], "{s} — {s}", .{ choice.label, choice.detail }); + used += labels[i].len; + } + } + const back = current.choices.len; + labels[back] = "Back"; + labels[back + 1] = "Restart"; + labels[back + 2] = "Quit"; + const chosen = (try prompts.choose(stdout, stdin, labels[0 .. back + 3])) orelse break; + if (chosen < back) { + try history.append(allocator, current.choices[chosen].next); + } else if (chosen == back) { if (history.items.len > 1) { _ = history.pop(); - current_node = history.items[history.items.len - 1]; - // 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 screen.render(stdout, "[Already at the beginning]\n"); + try stdout.writeAll("\nAlready at the beginning.\n"); } - } else if (std.ascii.eqlIgnoreCase(answer, "reset") or std.ascii.eqlIgnoreCase(answer, "r")) { + } else if (chosen == back + 1) { history.clearRetainingCapacity(); - current_node = &decision_tree; - try history.append(allocator, current_node); - try screen.render(stdout, "[Reset to beginning]\n"); - } else if (current_node.node_type == .Question) { - if (std.ascii.eqlIgnoreCase(answer, "yes") or std.ascii.eqlIgnoreCase(answer, "y")) { - if (current_node.yes) |next_node| { - current_node = next_node; - try history.append(allocator, current_node); - } - } else if (std.ascii.eqlIgnoreCase(answer, "no") or std.ascii.eqlIgnoreCase(answer, "n")) { - if (current_node.no) |next_node| { - current_node = next_node; - try history.append(allocator, current_node); - } - } else { - try screen.render(stdout, "[Invalid input. Please enter yes, no, back, reset, or quit]\n"); - } - } else { - try screen.render(stdout, "[Invalid command. Use back, reset, or quit]\n"); - } + try history.append(allocator, .work_type); + } else break; } + try stdout.writeAll("\nThank you for using Repolicense\n"); } diff --git a/src/tree.zig b/src/tree.zig index 2a62cd2..b849589 100644 --- a/src/tree.zig +++ b/src/tree.zig @@ -1,250 +1,234 @@ const catalog = @import("licenses.zig"); -pub const NodeType = enum { - Question, - Answer, +pub const Id = enum { + work_type, + software, + font, + font_owned, + font_existing, + out_of_scope, + ownership, + owned, + new_work, + existing_terms, + open_source, + reserved_rights, + open_source_help, + permissive, + mit, + apache, + advanced_permissive, + bsd2, + bsd3, + zero_bsd, + isc, + boost, + zlib, + unlicense, + copyleft, + copyleft_help, + whole_program, + library_scope, + file_scope, + eclipse_scope, + epl2, + epl1, + mpl, + lgpl21_version, + gpl3_only, + gpl3_later, + gpl2_only, + gpl2_later, + gpl2_version, + lgpl21_only, + lgpl21_later, + lgpl3_only, + lgpl3_later, + network, + ordinary, + agpl_only, + agpl_later, + font_terms, + font_help, + font_mismatch, + ofl, }; -pub const Node = struct { - node_type: NodeType, - content: []const u8, - elaboration: []const u8, - yes: ?*const Node, - no: ?*const Node, - license: ?catalog.License = null, +pub const Advice = enum { + out_of_scope, + ownership, + existing_terms, + font_mismatch, + reserved_rights, }; -pub const decision_tree = buildDecisionTree(); +pub const Choice = struct { + label: []const u8, + detail: []const u8, + next: Id, +}; -fn question(content: []const u8, elaboration: []const u8, yes: *const Node, no: *const Node) Node { - return .{ - .node_type = .Question, - .content = content, - .elaboration = elaboration, - .yes = yes, - .no = no, - }; -} +pub const Node = struct { + title: []const u8, + detail: []const u8, + choices: []const Choice = &.{}, + license: ?catalog.License = null, + advisory: ?Advice = null, +}; -fn recommendation(comptime license: catalog.License) Node { - return .{ - .node_type = .Answer, - .content = license.toString(), - .elaboration = license.description(), - .yes = null, - .no = null, - .license = license, - }; -} +const work_choices = [_]Choice{ + .{ .label = "Software", .detail = "Applications, libraries, tools, and source code.", .next = .software }, + .{ .label = "Font", .detail = "Font files and font families have distinct OFL terms.", .next = .font }, + .{ .label = "Other asset", .detail = "Documents, data, artwork, and other non-software assets are outside this questionnaire.", .next = .out_of_scope }, +}; +const ownership_choices = [_]Choice{ + .{ .label = "I have the rights to license it", .detail = "You control the relevant rights or have authority from all rightsholders.", .next = .owned }, + .{ .label = "Rights are uncertain or shared", .detail = "Resolve author, employer, contributor, and third-party rights first.", .next = .ownership }, +}; +const font_ownership_choices = [_]Choice{ + .{ .label = "I have the rights to license it", .detail = "You control the relevant font rights or have authority from all rightsholders.", .next = .font_owned }, + .{ .label = "Rights are uncertain or shared", .detail = "Resolve author, employer, contributor, and third-party font rights first.", .next = .ownership }, +}; +const open_help_choices = [_]Choice{ + .{ .label = "Permissive reuse", .detail = "Broad reuse with license-specific notice and other conditions.", .next = .permissive }, + .{ .label = "Copyleft sharing", .detail = "Choose file, library, or whole-program coverage.", .next = .copyleft }, +}; +const copyleft_help_choices = [_]Choice{ + .{ .label = "Whole combined program", .detail = "Strong project-wide sharing obligations for distributed covered combinations.", .next = .whole_program }, + .{ .label = "Library boundary", .detail = "Keep library modifications covered while permitting separate applications under other terms, subject to relinking requirements.", .next = .library_scope }, + .{ .label = "File by file", .detail = "Keep covered files and their modifications under copyleft while separate files may use other terms.", .next = .file_scope }, + .{ .label = "Eclipse ecosystem", .detail = "Consider EPL only when its contribution and distribution model fits the project.", .next = .eclipse_scope }, +}; +const new_or_existing_choices = [_]Choice{ + .{ .label = "New work under my control", .detail = "Choose terms for a work whose licensing you can decide.", .next = .new_work }, + .{ .label = "Existing, forked, or community project", .detail = "Preserve grants already made and check contributor authority and compatibility; do not assume you may replace the license.", .next = .existing_terms }, +}; +const new_work_choices = [_]Choice{ + .{ .label = "Open-source permissions fit", .detail = "Allow use, modification, and redistribution under published terms.", .next = .open_source }, + .{ .label = "I need to reserve these rights", .detail = "Open-source licenses grant broad reuse; this tool does not draft proprietary terms.", .next = .reserved_rights }, + .{ .label = "Help me decide", .detail = "Compare common permission and sharing models before choosing.", .next = .open_source_help }, +}; +const permission_choices = [_]Choice{ + .{ .label = "Permissive: MIT or Apache-2.0", .detail = "Broad reuse, including proprietary derivatives; Apache adds an express contributor patent grant and NOTICE conditions.", .next = .permissive }, + .{ .label = "Copyleft: share covered changes", .detail = "Select file-, library-, or whole-program sharing obligations.", .next = .copyleft }, + .{ .label = "Help me decide", .detail = "Review what broad reuse and copyleft sharing mean before choosing a family.", .next = .open_source_help }, +}; +const permissive_choices = [_]Choice{ + .{ .label = "MIT", .detail = "Common concise permissive terms; retain copyright and license notices.", .next = .mit }, + .{ .label = "Apache-2.0", .detail = "Common permissive terms with express contributor patent rights and additional notice obligations.", .next = .apache }, + .{ .label = "Advanced permissive choices", .detail = "Compare BSD, ISC, 0BSD, Boost, Zlib, and public-domain dedication terms.", .next = .advanced_permissive }, +}; +const advanced_permissive_choices = [_]Choice{ + .{ .label = "BSD-2-Clause", .detail = "BSD ecosystem; retain source notices and reproduce them for binary distributions.", .next = .bsd2 }, + .{ .label = "BSD-3-Clause", .detail = "BSD-2-Clause terms plus an explicit no-endorsement condition.", .next = .bsd3 }, + .{ .label = "0BSD", .detail = "Permits copying and distribution without requiring attribution or license notice retention.", .next = .zero_bsd }, + .{ .label = "ISC", .detail = "Short permissive terms; retain the copyright and permission notice.", .next = .isc }, + .{ .label = "Boost Software License 1.0", .detail = "Source notice conditions; solely machine-executable object code is exempt from that condition. Not BUSL.", .next = .boost }, + .{ .label = "Zlib", .detail = "Retain notices in source, mark altered source, and do not misrepresent origin.", .next = .zlib }, + .{ .label = "Unlicense", .detail = "Public-domain dedication where possible, with a fallback permission; not the same as retaining copyright under 0BSD.", .next = .unlicense }, +}; +const copyleft_choices = [_]Choice{ + .{ .label = "Whole combined program", .detail = "Strong project-wide sharing obligations for distributed covered combinations.", .next = .whole_program }, + .{ .label = "Library boundary", .detail = "Keep library modifications covered while permitting separate applications under other terms, subject to relinking requirements.", .next = .library_scope }, + .{ .label = "File by file", .detail = "Keep covered files and their modifications under copyleft while separate files may use other terms.", .next = .file_scope }, + .{ .label = "Eclipse ecosystem", .detail = "Consider EPL only when its contribution and distribution model fits the project.", .next = .eclipse_scope }, + .{ .label = "Help me decide", .detail = "Compare the boundaries where source-sharing obligations apply.", .next = .copyleft_help }, +}; +const library_choices = [_]Choice{ + .{ .label = "LGPL-3.0-only", .detail = "Library-level copyleft with LGPL version 3 additional permissions.", .next = .lgpl3_only }, + .{ .label = "LGPL-3.0-or-later", .detail = "Allow later LGPL versions as well as version 3.", .next = .lgpl3_later }, + .{ .label = "Existing LGPL-2.1 grant", .detail = "Use version 2.1 only when rights permit that specific grant.", .next = .lgpl21_version }, +}; +const font_choices = [_]Choice{ + .{ .label = "Accept OFL conditions", .detail = "Fonts and derivatives stay under OFL, preserve notices, respect Reserved Font Names, and cannot be sold by themselves.", .next = .ofl }, + .{ .label = "I cannot accept all conditions", .detail = "Do not apply OFL where these obligations conflict with your requirements.", .next = .font_mismatch }, + .{ .label = "Help me decide", .detail = "Review font-specific permissions and restrictions before deciding.", .next = .font_help }, +}; +const font_help_choices = [_]Choice{ + .{ .label = "Accept OFL conditions", .detail = "Fonts and derivatives stay under OFL, preserve notices, respect Reserved Font Names, and cannot be sold by themselves.", .next = .ofl }, + .{ .label = "I cannot accept all conditions", .detail = "Do not apply OFL where these obligations conflict with your requirements.", .next = .font_mismatch }, +}; +const gpl2_choices = [_]Choice{ + .{ .label = "GPL-2.0-only", .detail = "Grant GPL version 2 only.", .next = .gpl2_only }, + .{ .label = "GPL-2.0-or-later", .detail = "Grant GPL version 2 and later versions.", .next = .gpl2_later }, +}; +const lgpl21_choices = [_]Choice{ + .{ .label = "LGPL-2.1-only", .detail = "Fix the grant to LGPL version 2.1.", .next = .lgpl21_only }, + .{ .label = "LGPL-2.1-or-later", .detail = "Allow later LGPL versions.", .next = .lgpl21_later }, +}; -fn advice(content: []const u8, elaboration: []const u8) Node { - return .{ - .node_type = .Answer, - .content = content, - .elaboration = elaboration, - .yes = null, - .no = null, +pub fn node(id: Id) Node { + return switch (id) { + .work_type => .{ .title = "What are you licensing?", .detail = "Start with the kind of work; font and software rights follow separate paths.", .choices = &work_choices }, + .software => .{ .title = "Who can authorize licensing this software?", .detail = "Ownership and authority are independent of which license obligations you prefer.", .choices = &ownership_choices }, + .font => .{ .title = "Who can authorize licensing this font?", .detail = "Confirm rights in the font files and contributions before selecting terms.", .choices = &font_ownership_choices }, + .out_of_scope => .{ .title = "This asset is outside the questionnaire", .detail = "The software and font recommendations here do not assess licenses for documents, datasets, artwork, or other assets.", .advisory = .out_of_scope }, + .ownership => .{ .title = "Resolve ownership and authority first", .detail = "Identify authors, employers, contributors, and third-party components. Obtain the required permission before choosing terms.", .advisory = .ownership }, + .owned => .{ .title = "Is this new work or an existing project?", .detail = "For existing projects, forks, and communities, preserve existing grants and check compatibility rather than assuming unilateral relicensing authority.", .choices = &new_or_existing_choices }, + .font_owned => .{ .title = "Is this an existing font project?", .detail = "Preserve existing font grants and Reserved Font Name rules; do not assume you may replace community or contributor terms.", .choices = &.{ + .{ .label = "New font under my control", .detail = "Choose font terms for work whose licensing you control.", .next = .font_terms }, + .{ .label = "Existing, forked, or community font", .detail = "Preserve existing grants and verify authority before changing terms.", .next = .font_existing }, + } }, + .font_existing => .{ .title = "Preserve the font project's existing terms", .detail = "Review the existing license, Reserved Font Names, contributor permissions, and compatibility. Do not assume you can relicense a community font.", .advisory = .existing_terms }, + .new_work => .{ .title = "Do broad open-source permissions fit?", .detail = "Open source allows anyone to use, modify, and redistribute the work, including commercially. Copyleft can require source sharing; it cannot prohibit commercial use or competitors.", .choices = &new_work_choices }, + .existing_terms => .{ .title = "Preserve the project's existing terms", .detail = "Review the current license grants, contributor agreements, provenance, and compatibility with dependencies. Do not upgrade versions or relicense without authority from the relevant rightsholders.", .advisory = .existing_terms }, + .open_source => .{ .title = "Choose the permission and sharing model", .detail = "Permissive licenses allow broad reuse subject to their conditions; copyleft licenses impose source-sharing obligations on covered distributions or services.", .choices = &permission_choices }, + .reserved_rights => .{ .title = "Open-source permissions do not match", .detail = "Open-source licenses grant use, modification, and redistribution rights. Review proprietary or specialized terms with qualified counsel; this tool does not draft or recommend them.", .advisory = .reserved_rights }, + .open_source_help => .{ .title = "Compare open-source licensing models", .detail = "Permissive terms such as MIT allow proprietary derivatives while requiring notices. Apache-2.0 also supplies an express contributor patent grant and NOTICE conditions. Copyleft terms require covered modifications or combinations to remain under specified terms when distributed; AGPL adds a source offer for modified network versions. Choose a model below.", .choices = &open_help_choices }, + .permissive => .{ .title = "Choose permissive terms", .detail = "MIT is a common concise choice; Apache-2.0 adds express patent rights and additional notice conditions. Advanced options have distinct notice or attribution rules.", .choices = &permissive_choices }, + .mit => outcome(.MIT, "MIT fits a common goal of broad reuse with a simple requirement to retain copyright and license notices."), + .apache => outcome(.@"Apache-2.0", "Apache-2.0 fits broad reuse when an express contributor patent grant and its NOTICE and change-marking conditions are desired."), + .advanced_permissive => .{ .title = "Choose an advanced permissive license", .detail = "These are not interchangeable defaults: compare notice retention, endorsement, source marking, patent terms, and public-domain dedication before selecting.", .choices = &advanced_permissive_choices }, + .bsd2 => outcome(.@"BSD-2-Clause", "BSD-2-Clause fits a BSD ecosystem when its source and binary notice conditions are acceptable."), + .bsd3 => outcome(.@"BSD-3-Clause", "BSD-3-Clause fits when BSD notice conditions and an explicit no-endorsement term are desired."), + .zero_bsd => outcome(.@"0BSD", "0BSD fits when redistribution should not require retaining attribution or license notices."), + .isc => outcome(.ISC, "ISC fits when short permissive terms and retention of its copyright and permission notice are desired."), + .boost => outcome(.@"BSL-1.0", "Boost Software License 1.0 fits when its source notice conditions and machine-executable distribution exception are suitable."), + .zlib => outcome(.Zlib, "Zlib fits when preserving source notices, marking altered source, and avoiding origin misrepresentation are suitable."), + .unlicense => outcome(.Unlicense, "The Unlicense fits a public-domain dedication goal, subject to local enforceability and third-party rights."), + .copyleft => .{ .title = "Where should copyleft obligations apply?", .detail = "File-level rules cover distributed modified files; library-level rules preserve a library boundary with relinking rights; whole-program rules cover distributed combined works. Network source obligations are asked only for the whole-program path.", .choices = ©left_choices }, + .copyleft_help => .{ .title = "Compare copyleft boundaries", .detail = "MPL applies to covered files, allowing separate files under other terms. LGPL applies at a library boundary and requires users' ability to modify/relink the library under its conditions. GPL covers distributed combined programs. AGPL adds a source offer to remote users of a modified network version. Choose the scope that matches your intended architecture.", .choices = ©left_help_choices }, + .whole_program => .{ .title = "Should remote users of modified network versions receive source?", .detail = "AGPL adds a source offer to remote users of modified network versions. Choose it when you want this additional obligation; serving software over a network alone does not require AGPL.", .choices = &.{ + .{ .label = "Yes", .detail = "Include the network source offer; continue to the AGPL version grant.", .next = .network }, + .{ .label = "No", .detail = "Use ordinary distribution copyleft; continue to GPL version and grant choices.", .next = .ordinary }, + } }, + .library_scope => .{ .title = "Choose library copyleft terms", .detail = "LGPL permits separately licensed applications under conditions including notices, source access, and relinking. It does not allow copied library code to be relicensed. Use the 2.1 route only with authority to grant it.", .choices = &library_choices }, + .file_scope => .{ .title = "Choose file-level copyleft", .detail = "MPL-2.0 keeps covered distributed files and modifications under MPL while separate files may use other licenses.", .choices = &.{.{ .label = "MPL-2.0", .detail = "Select file-level copyleft terms.", .next = .mpl }} }, + .eclipse_scope => .{ .title = "Choose EPL only for an Eclipse-project fit", .detail = "EPL has its own contribution, patent, distribution, and commercial-distributor conditions. EPL-1.0 should be selected only to preserve an existing explicit requirement.", .choices = &.{ + .{ .label = "EPL-2.0", .detail = "Use the current EPL version when the Eclipse ecosystem is required.", .next = .epl2 }, + .{ .label = "Existing EPL-1.0 project requirement", .detail = "Preserve version 1.0 only where existing rights and project terms require it.", .next = .epl1 }, + } }, + .epl2 => outcome(.@"EPL-2.0", "EPL-2.0 fits a project that specifically needs the Eclipse ecosystem and accepts its distinct conditions."), + .epl1 => outcome(.@"EPL-1.0", "EPL-1.0 fits only an existing project requirement that authorizes that version."), + .mpl => outcome(.@"MPL-2.0", "MPL-2.0 fits a file-level sharing goal: covered files remain under MPL while separate files may use other terms."), + .lgpl21_version => .{ .title = "Select the LGPL-2.1 grant", .detail = "Choose an LGPL-2.1 variant only if you hold authority to grant those terms; later LGPL versions are not implied by LGPL-2.1-only.", .choices = &lgpl21_choices }, + .gpl2_version => .{ .title = "Select the GPL-2.0 grant", .detail = "For new code with a specific GPL-2.0 compatibility requirement, choose the grant you are authorized to make. This cannot change an existing component's permissions.", .choices = &gpl2_choices }, + .gpl3_only => outcome(.@"GPL-3.0-only", "GPL-3.0-only fits whole-program copyleft when the grant should remain fixed to version 3."), + .gpl3_later => outcome(.@"GPL-3.0-or-later", "GPL-3.0-or-later fits whole-program copyleft when recipients may use later GPL versions."), + .gpl2_only => outcome(.@"GPL-2.0-only", "GPL-2.0-only fits only a project with authority and a specific reason to grant version 2 alone."), + .gpl2_later => outcome(.@"GPL-2.0-or-later", "GPL-2.0-or-later fits only a project with authority to grant version 2 and later versions."), + .lgpl21_only => outcome(.@"LGPL-2.1-only", "LGPL-2.1-only fits a library when version 2.1 is the authorized grant; its GPL conversion option is not an LGPL upgrade."), + .lgpl21_later => outcome(.@"LGPL-2.1-or-later", "LGPL-2.1-or-later fits a library when the rightsholders authorize later LGPL versions."), + .lgpl3_only => outcome(.@"LGPL-3.0-only", "LGPL-3.0-only fits library-level sharing when the grant is fixed to version 3."), + .lgpl3_later => outcome(.@"LGPL-3.0-or-later", "LGPL-3.0-or-later fits library-level sharing when recipients may use later LGPL versions."), + .network => .{ .title = "Select an AGPL version grant", .detail = "AGPL adds a source offer to remote users of modified network versions. Choose whether the authorized grant permits later AGPL versions.", .choices = &.{ + .{ .label = "AGPL-3.0-only", .detail = "Fix the grant to AGPL version 3.", .next = .agpl_only }, + .{ .label = "AGPL-3.0-or-later", .detail = "Allow later AGPL versions.", .next = .agpl_later }, + } }, + .ordinary => .{ .title = "Select a GPL version grant", .detail = "GPL requires sharing covered distributed combined works. Version 2 is included only as an explicit compatibility choice, not an automatic upgrade or default.", .choices = &.{ + .{ .label = "GPL-3.0-only", .detail = "Fix the grant to GPL version 3.", .next = .gpl3_only }, + .{ .label = "GPL-3.0-or-later", .detail = "Allow later GPL versions.", .next = .gpl3_later }, + .{ .label = "GPL-2.0 compatibility requirement", .detail = "Choose version 2 for a specific compatibility requirement when you control the grant.", .next = .gpl2_version }, + } }, + .agpl_only => outcome(.@"AGPL-3.0-only", "AGPL-3.0-only fits whole-program copyleft when its network source offer is intended and the grant is fixed to version 3."), + .agpl_later => outcome(.@"AGPL-3.0-or-later", "AGPL-3.0-or-later fits whole-program copyleft when its network source offer is intended and later AGPL versions are permitted."), + .font_terms => .{ .title = "Can you accept OFL's font conditions?", .detail = "OFL requires font and derivative redistribution under OFL, retention of notices, respect for Reserved Font Names, and prohibits selling the font by itself. Documents made with the font are not thereby covered.", .choices = &font_choices }, + .font_help => .{ .title = "Review OFL font conditions", .detail = "OFL permits using, embedding, modifying, and redistributing fonts. Font files and derivatives stay under OFL; preserve notices, respect Reserved Font Names, and do not sell fonts by themselves. Choose only if those conditions fit.", .choices = &font_help_choices }, + .font_mismatch => .{ .title = "OFL conditions do not fit", .detail = "Do not use OFL if its same-license font terms, notices, Reserved Font Name rules, or standalone-sale restriction conflict with your needs. Review existing font rights or seek font-specific guidance.", .advisory = .font_mismatch }, + .ofl => outcome(.@"OFL-1.1", "OFL-1.1 fits font use and redistribution when the font-specific same-license, notice, Reserved Font Name, and standalone-sale conditions are accepted."), }; } -fn buildDecisionTree() Node { - const font_advice = advice( - "No font license recommendation matches these preferences.", - "Review the font's existing terms and permissions; font licensing and software licensing differ, and this tool only recommends the SIL Open Font License when its stated goals fit.", - ); - const ownership_advice = advice( - "Verify that you have the right to license this software.", - "Identify authors, employers, contributors, and third-party components, then resolve ownership and existing obligations before selecting a license.", - ); - const reserved_rights_advice = advice( - "An open-source license does not match these reserved-rights preferences.", - "Open-source licenses grant broad rights to use, modify, and redistribute. Review proprietary or other specialized terms with qualified counsel; this tool does not draft or recommend a proprietary license.", - ); - const unmatched_copyleft_advice = advice( - "No copyleft recommendation matches these preferences.", - "The selected scope does not match the copyleft options represented here. Compare the obligations of project-wide, library-boundary, and file-level copyleft before choosing a license.", - ); - - const mit = recommendation(.MIT); - const bsd2 = recommendation(.@"BSD-2-Clause"); - const bsd3 = recommendation(.@"BSD-3-Clause"); - const apache = recommendation(.@"Apache-2.0"); - const zero_bsd = recommendation(.@"0BSD"); - const isc = recommendation(.ISC); - const boost = recommendation(.@"BSL-1.0"); - const zlib = recommendation(.Zlib); - const unlicense = recommendation(.Unlicense); - const ofl = recommendation(.@"OFL-1.1"); - - const isc_preference = question( - "Do you prefer the ISC ecosystem and wording?", - "ISC is a short permissive license requiring its copyright and permission notice to be retained in copies. It does not waive notices or include an express contributor patent grant.", - &isc, - &mit, - ); - const bsd_preference = question( - "Do you prefer the BSD-2-Clause ecosystem and wording?", - "BSD-2-Clause preserves notices in source and requires their reproduction in documentation or other materials with binary distributions, without BSD-3-Clause's endorsement clause.", - &bsd2, - &isc_preference, - ); - const no_endorsement = question( - "Do you want an explicit clause prohibiting endorsement using contributors' names?", - "BSD-3-Clause adds an explicit prohibition on using the project or contributors' names to endorse or promote derived products without permission.", - &bsd3, - &bsd_preference, - ); - const source_marking = question( - "Do you require altered source versions to be marked and their origin not misrepresented?", - "Zlib prohibits misrepresenting origin, requires altered source versions to be plainly marked, and preserves its notice in source distributions. Acknowledgment in product documentation is appreciated but not required.", - &zlib, - &boost, - ); - const omit_binary_notice = question( - "Is preserving notices in source enough, without a binary notice-copy requirement?", - "Boost exempts solely machine-executable distributions from its notice condition. Zlib preserves notices in source and adds origin/altered-source rules. Choose no to retain notice requirements for both source and binary copies.", - &source_marking, - &no_endorsement, - ); - const waive_notice = question( - "Do you want to waive the requirement to preserve copyright and permission notices?", - "0BSD permits redistribution in source or binary form without requiring a copyright notice or license text to be retained.", - &zero_bsd, - &omit_binary_notice, - ); - const dedicate = question( - "Do you want to dedicate the work to the public domain?", - "The Unlicense dedicates the work to the public domain to the extent possible and provides a fallback broad permission where that dedication is not effective.", - &unlicense, - &waive_notice, - ); - const patent_grant = question( - "Do you need an express contributor patent license?", - "Apache-2.0 includes an express patent license from contributors, subject to its terms and patent-litigation termination provision; it also requires preservation of notices and specified NOTICE-file attributions.", - &apache, - &dedicate, - ); - - const gpl3_only = recommendation(.@"GPL-3.0-only"); - const gpl3_later = recommendation(.@"GPL-3.0-or-later"); - const gpl2_only = recommendation(.@"GPL-2.0-only"); - const gpl2_later = recommendation(.@"GPL-2.0-or-later"); - const agpl_only = recommendation(.@"AGPL-3.0-only"); - const agpl_later = recommendation(.@"AGPL-3.0-or-later"); - const lgpl3_only = recommendation(.@"LGPL-3.0-only"); - const lgpl3_later = recommendation(.@"LGPL-3.0-or-later"); - const lgpl21_only = recommendation(.@"LGPL-2.1-only"); - const lgpl21_later = recommendation(.@"LGPL-2.1-or-later"); - const mpl = recommendation(.@"MPL-2.0"); - const epl2 = recommendation(.@"EPL-2.0"); - const epl1 = recommendation(.@"EPL-1.0"); - - const gpl3_later_choice = question( - "Should recipients be allowed to use a later GPL version?", - "GPL-3.0-or-later lets recipients choose GPL version 3 or a later version published by the Free Software Foundation; GPL-3.0-only does not grant that later-version permission.", - &gpl3_later, - &gpl3_only, - ); - const gpl2_later_choice = question( - "Should recipients be allowed to use a later GPL version?", - "For an existing GPL-2.0 project, choose GPL-2.0-or-later only if the project grants permission to use later GPL versions; otherwise retain GPL-2.0-only.", - &gpl2_later, - &gpl2_only, - ); - const existing_gpl2 = question( - "Does an existing project require GPL version 2?", - "Select version 2 only to match an existing project's licensing requirement. Do not replace third-party GPL-2.0-only terms with another version.", - &gpl2_later_choice, - &gpl3_later_choice, - ); - const lgpl21_later_choice = question( - "Should recipients be allowed to use a later LGPL version?", - "LGPL-2.1-or-later permits later LGPL versions; LGPL-2.1-only does not. An existing library must already grant this permission, or you must own the rights to grant it.", - &lgpl21_later, - &lgpl21_only, - ); - const lgpl3_later_choice = question( - "Should recipients be allowed to use a later LGPL version?", - "LGPL-3.0-or-later permits later LGPL versions; LGPL-3.0-only fixes the library's LGPL grant to version 3.", - &lgpl3_later, - &lgpl3_only, - ); - const existing_lgpl21 = question( - "Does an existing library require LGPL version 2.1?", - "Select version 2.1 only to match an existing library. Its GPL conversion option is distinct from permission to upgrade to LGPL version 3.", - &lgpl21_later_choice, - &lgpl3_later_choice, - ); - const existing_epl1 = question( - "Does an existing project require EPL version 1.0?", - "Select version 1.0 only for an existing project's explicit requirement; otherwise use EPL version 2.0.", - &epl1, - &epl2, - ); - const eclipse_scope = question( - "Does your project need the Eclipse Public License ecosystem?", - "EPL has its own contribution, distribution, patent, and commercial-distributor obligations. It is not a generic 'business-friendly' fallback.", - &existing_epl1, - &unmatched_copyleft_advice, - ); - const file_scope = question( - "Do you want copyleft to apply file-by-file?", - "MPL-2.0 keeps distributed covered files under MPL, while separate files in a larger work may have different licenses.", - &mpl, - &eclipse_scope, - ); - const library_scope = question( - "Do you want copyleft at the library boundary, with relinking rights for users?", - "LGPL allows differently licensed applications to use a library if its source, notice, modification, and relinking conditions are met; copied library code cannot simply be relicensed.", - &existing_lgpl21, - &file_scope, - ); - const project_scope = question( - "Must copyleft cover the combined program, not only a library or individual files?", - "GPL applies to covered combined works when distributed, rather than merely the individual modified files.", - &existing_gpl2, - &library_scope, - ); - const agpl_later_choice = question( - "Should recipients be allowed to use a later AGPL version?", - "AGPL-3.0-or-later grants later AGPL-version permission; AGPL-3.0-only fixes the AGPL grant to version 3.", - &agpl_later, - &agpl_only, - ); - const network_scope = question( - "Must modified network-served versions provide corresponding source to remote users?", - "AGPL covers project-wide copyleft and adds a source offer to users interacting remotely with modified network versions; this network clause does not require publication of every private change.", - &agpl_later_choice, - &project_scope, - ); - const copyleft_scope = question( - "Do you want source-sharing obligations for distributed modifications or modified network services?", - "Ordinary copyleft is triggered by distribution of covered works, not private modification alone. AGPL additionally covers remote users of modified network versions. Choose yes to explore either obligation.", - &network_scope, - &patent_grant, - ); - const rights = question( - "Do you allow anyone to use, modify, and redistribute the software, including commercially?", - "Open-source licenses grant these broad rights. If you need to reserve them, the open-source recommendations here do not fit.", - ©left_scope, - &reserved_rights_advice, - ); - const software = question( - "Are you licensing software that you have the right to license?", - "Confirm ownership, contributor permissions, employer rights, and third-party obligations before choosing software terms. Content other than software or fonts is outside this tree.", - &rights, - &ownership_advice, - ); - const font = question( - "Do you allow the font to be used, embedded, modified, and redistributed?", - "OFL permits those uses subject to its notices, Reserved Font Names, and restrictions on selling fonts by themselves. Confirm that you have the right to license the font.", - &ofl, - &font_advice, - ); - return question( - "Are you licensing a font?", - "OFL addresses font-specific obligations. Software follows a separate path; documents and other assets are outside this tree.", - &font, - &software, - ); +fn outcome(license: catalog.License, detail: []const u8) Node { + return .{ .title = license.toString(), .detail = detail, .license = license }; } diff --git a/src/tree_test.zig b/src/tree_test.zig index 844c3eb..7ff0423 100644 --- a/src/tree_test.zig +++ b/src/tree_test.zig @@ -3,91 +3,91 @@ const testing = std.testing; const tree = @import("tree.zig"); const catalog = @import("licenses.zig"); -const Expected = union(enum) { license: []const u8, advice: void }; -const PathCase = struct { path: []const bool, expected: Expected }; - -const path_cases = [_]PathCase{ - .{ .path = &.{ true, true }, .expected = .{ .license = "OFL-1.1" } }, - .{ .path = &.{ true, false }, .expected = .{ .advice = {} } }, - .{ .path = &.{ false, false }, .expected = .{ .advice = {} } }, - .{ .path = &.{ false, true, false }, .expected = .{ .advice = {} } }, - .{ .path = &.{ false, true, true, false, true }, .expected = .{ .license = "Apache-2.0" } }, - .{ .path = &.{ false, true, true, false, false, true }, .expected = .{ .license = "Unlicense" } }, - .{ .path = &.{ false, true, true, false, false, false, true }, .expected = .{ .license = "0BSD" } }, - .{ .path = &.{ false, true, true, false, false, false, false, true, false }, .expected = .{ .license = "BSL-1.0" } }, - .{ .path = &.{ false, true, true, false, false, false, false, true, true }, .expected = .{ .license = "Zlib" } }, - .{ .path = &.{ false, true, true, false, false, false, false, false, true }, .expected = .{ .license = "BSD-3-Clause" } }, - .{ .path = &.{ false, true, true, false, false, false, false, false, false, true }, .expected = .{ .license = "BSD-2-Clause" } }, - .{ .path = &.{ false, true, true, false, false, false, false, false, false, false, true }, .expected = .{ .license = "ISC" } }, - .{ .path = &.{ false, true, true, false, false, false, false, false, false, false, false }, .expected = .{ .license = "MIT" } }, - .{ .path = &.{ false, true, true, true, true, true }, .expected = .{ .license = "AGPL-3.0-or-later" } }, - .{ .path = &.{ false, true, true, true, true, false }, .expected = .{ .license = "AGPL-3.0-only" } }, - .{ .path = &.{ false, true, true, true, false, true, true, true }, .expected = .{ .license = "GPL-2.0-or-later" } }, - .{ .path = &.{ false, true, true, true, false, true, true, false }, .expected = .{ .license = "GPL-2.0-only" } }, - .{ .path = &.{ false, true, true, true, false, true, false, true }, .expected = .{ .license = "GPL-3.0-or-later" } }, - .{ .path = &.{ false, true, true, true, false, true, false, false }, .expected = .{ .license = "GPL-3.0-only" } }, - .{ .path = &.{ false, true, true, true, false, false, true, true, true }, .expected = .{ .license = "LGPL-2.1-or-later" } }, - .{ .path = &.{ false, true, true, true, false, false, true, true, false }, .expected = .{ .license = "LGPL-2.1-only" } }, - .{ .path = &.{ false, true, true, true, false, false, true, false, true }, .expected = .{ .license = "LGPL-3.0-or-later" } }, - .{ .path = &.{ false, true, true, true, false, false, true, false, false }, .expected = .{ .license = "LGPL-3.0-only" } }, - .{ .path = &.{ false, true, true, true, false, false, false, true }, .expected = .{ .license = "MPL-2.0" } }, - .{ .path = &.{ false, true, true, true, false, false, false, false, true, true }, .expected = .{ .license = "EPL-1.0" } }, - .{ .path = &.{ false, true, true, true, false, false, false, false, true, false }, .expected = .{ .license = "EPL-2.0" } }, - .{ .path = &.{ false, true, true, true, false, false, false, false, false }, .expected = .{ .advice = {} } }, -}; +test "all catalog licenses have reachable recommendation outcomes" { + var reached: [catalog.all.len]bool = @splat(false); + var seen: [std.meta.tags(tree.Id).len]bool = @splat(false); + var stack: [std.meta.tags(tree.Id).len]bool = @splat(false); + try visit(.work_type, &seen, &stack, &reached); + for (reached) |value| try testing.expect(value); +} -test "preference paths reach every recommendation and advisory" { - for (path_cases) |case| { - var node: *const tree.Node = &tree.decision_tree; - for (case.path) |answer| { - try testing.expectEqual(tree.NodeType.Question, node.node_type); - node = if (answer) node.yes orelse return error.MissingQuestionBranch else node.no orelse return error.MissingQuestionBranch; +fn visit(id: tree.Id, seen: *[std.meta.tags(tree.Id).len]bool, stack: *[std.meta.tags(tree.Id).len]bool, reached: *[catalog.all.len]bool) !void { + const index = @backingInt(id); + try testing.expect(!stack[index]); + if (seen[index]) return; + stack[index] = true; + seen[index] = true; + const current = tree.node(id); + if (current.license) |license| { + var found = false; + inline for (catalog.all, 0..) |known, license_index| { + if (known == license) { + reached[license_index] = true; + found = true; + } } - try testing.expectEqual(tree.NodeType.Answer, node.node_type); - switch (case.expected) { - .license => |identifier| { - const license = catalog.License.fromString(identifier) orelse return error.UnknownExpectedLicense; - try testing.expectEqual(license, node.license.?); - }, - .advice => try testing.expect(node.license == null), + try testing.expect(found); + try testing.expect(current.choices.len == 0); + try testing.expect(current.advisory == null); + } else if (current.advisory != null) { + try testing.expect(current.choices.len == 0); + } else { + try testing.expect(current.choices.len > 0); + for (current.choices) |choice| try visit(choice.next, seen, stack, reached); + } + stack[index] = false; +} + +fn route(ids: []const tree.Id) !tree.Id { + var current = ids[0]; + for (ids[1..]) |destination| { + var found = false; + for (tree.node(current).choices) |choice| { + if (choice.next == destination) { + current = destination; + found = true; + break; + } } + if (!found) return error.NonEdgeRoute; } + return current; } -fn countTrue(values: []const bool) usize { - var count: usize = 0; - for (values) |value| if (value) { - count += 1; - }; - return count; +test "unsupported assets, ownership, existing terms, and font mismatch are distinct outcomes" { + try testing.expectEqual(tree.Advice.out_of_scope, tree.node(try route(&.{ .work_type, .out_of_scope })).advisory.?); + try testing.expectEqual(tree.Advice.ownership, tree.node(try route(&.{ .work_type, .software, .ownership })).advisory.?); + try testing.expectEqual(tree.Advice.existing_terms, tree.node(try route(&.{ .work_type, .software, .owned, .existing_terms })).advisory.?); + try testing.expectEqual(tree.Advice.font_mismatch, tree.node(try route(&.{ .work_type, .font, .font_owned, .font_terms, .font_mismatch })).advisory.?); + try testing.expectEqual(tree.Advice.reserved_rights, tree.node(try route(&.{ .work_type, .software, .owned, .new_work, .reserved_rights })).advisory.?); } -test "decision graph has complete branches, valid leaves, reachability, and no cycles" { - var stack: [128]*const tree.Node = undefined; - var recommendations: [catalog.all.len]bool = @splat(false); - try inspect(&tree.decision_tree, &stack, 0, &recommendations); - try testing.expectEqual(catalog.all.len, countTrue(&recommendations)); +test "font help preserves the explicit acceptance boundary" { + try testing.expectEqual(tree.Advice.font_mismatch, tree.node(try route(&.{ .work_type, .font, .font_owned, .font_terms, .font_help, .font_mismatch })).advisory.?); + try testing.expectEqual(catalog.License.@"OFL-1.1", tree.node(try route(&.{ .work_type, .font, .font_owned, .font_terms, .font_help, .ofl })).license.?); } -fn inspect(node: *const tree.Node, stack: *[128]*const tree.Node, depth: usize, recommendations: *[catalog.all.len]bool) !void { - try testing.expect(depth < stack.len); - for (stack[0..depth]) |ancestor| try testing.expect(ancestor != node); - stack[depth] = node; +test "common new software path reaches MIT within six actual selections" { + const ids = [_]tree.Id{ .work_type, .software, .owned, .new_work, .open_source, .permissive, .mit }; + try testing.expectEqual(catalog.License.MIT, tree.node(try route(&ids)).license.?); + try testing.expect(ids.len - 1 <= 6); +} - switch (node.node_type) { - .Question => { - try testing.expect(node.license == null); - try inspect(node.yes orelse return error.MissingQuestionBranch, stack, depth + 1, recommendations); - try inspect(node.no orelse return error.MissingQuestionBranch, stack, depth + 1, recommendations); - }, - .Answer => { - try testing.expect(node.yes == null); - try testing.expect(node.no == null); - if (node.license) |license| { - inline for (catalog.all, 0..) |known, index| { - if (known == license) recommendations[index] = true; - } - } - }, - } +test "advanced permissive routes remain reachable" { + try testing.expectEqual(catalog.License.Zlib, tree.node(try route(&.{ .work_type, .software, .owned, .new_work, .open_source, .permissive, .advanced_permissive, .zlib })).license.?); + try testing.expectEqual(catalog.License.@"EPL-1.0", tree.node(try route(&.{ .work_type, .software, .owned, .new_work, .open_source, .copyleft, .eclipse_scope, .epl1 })).license.?); +} + +test "copyleft scope and network preference select distinct families" { + try testing.expectEqual(catalog.License.@"AGPL-3.0-only", tree.node(try route(&.{ .work_type, .software, .owned, .new_work, .open_source, .copyleft, .whole_program, .network, .agpl_only })).license.?); + try testing.expectEqual(catalog.License.@"GPL-3.0-only", tree.node(try route(&.{ .work_type, .software, .owned, .new_work, .open_source, .copyleft, .whole_program, .ordinary, .gpl3_only })).license.?); + try testing.expectEqual(catalog.License.@"MPL-2.0", tree.node(try route(&.{ .work_type, .software, .owned, .new_work, .open_source, .copyleft, .file_scope, .mpl })).license.?); + try testing.expectEqual(catalog.License.@"LGPL-3.0-only", tree.node(try route(&.{ .work_type, .software, .owned, .new_work, .open_source, .copyleft, .library_scope, .lgpl3_only })).license.?); +} + +test "GNU later-version grants are explicit choices" { + try testing.expectEqual(catalog.License.@"GPL-3.0-only", tree.node(try route(&.{ .work_type, .software, .owned, .new_work, .open_source, .copyleft, .whole_program, .ordinary, .gpl3_only })).license.?); + try testing.expectEqual(catalog.License.@"GPL-3.0-or-later", tree.node(try route(&.{ .work_type, .software, .owned, .new_work, .open_source, .copyleft, .whole_program, .ordinary, .gpl3_later })).license.?); + try testing.expectEqual(catalog.License.@"LGPL-2.1-only", tree.node(try route(&.{ .work_type, .software, .owned, .new_work, .open_source, .copyleft, .library_scope, .lgpl21_version, .lgpl21_only })).license.?); + try testing.expectEqual(catalog.License.@"LGPL-2.1-or-later", tree.node(try route(&.{ .work_type, .software, .owned, .new_work, .open_source, .copyleft, .library_scope, .lgpl21_version, .lgpl21_later })).license.?); } diff --git a/src/ui.zig b/src/ui.zig index 48b553d..7818954 100644 --- a/src/ui.zig +++ b/src/ui.zig @@ -1,42 +1,280 @@ -pub const Screen = struct { - lines_printed: usize, +const std = @import("std"); - pub fn init() Screen { - return Screen{ .lines_printed = 0 }; +pub const Prompter = struct { + process: std.process.Init, + gum_enabled: bool, + gum_missing: bool = false, + replace_view: bool, + + pub fn init(process: std.process.Init, plain: bool) Prompter { + const io = process.io; + const term = process.environ_map.get("TERM") orelse ""; + const tty = (std.Io.File.stdin().isTty(io) catch false) and (std.Io.File.stdout().isTty(io) catch false) and (std.Io.File.stderr().isTty(io) catch false); + return .{ .process = process, .gum_enabled = !plain and tty and !std.mem.eql(u8, term, "dumb"), .replace_view = tty and !std.mem.eql(u8, term, "dumb") }; } - /// 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 { - try self.clear(stdout); + pub fn usingGum(self: *const Prompter) bool { + return self.gum_enabled and !self.gum_missing; + } - // Print new content - try stdout.print("{s}", .{content}); + pub fn beginView(self: *const Prompter, stdout: *std.Io.Writer) !void { + if (self.replace_view) try stdout.writeAll("\x1b[2J\x1b[H"); + } - // Count lines in content to know how many to clear next time. - var count: usize = 1; - for (content) |c| { - if (c == '\n') count += 1; + pub fn renderView(self: *Prompter, stdout: *std.Io.Writer, heading: []const u8, title: []const u8, body: []const u8) !void { + try self.beginView(stdout); + if (self.usingGum()) { + self.style(stdout, &.{ "--foreground=212", "--bold", "--margin=0 0 1 1", "REPOLICENSE / license guidance" }) catch |err| switch (err) { + error.FileNotFound => self.gum_missing = true, + else => return err, + }; + } + if (self.usingGum()) { + var content_buffer: [8192]u8 = undefined; + const content = try std.fmt.bufPrint(&content_buffer, "{s}\n\n{s}\n\n{s}", .{ heading, title, body }); + var width_buffer: [32]u8 = undefined; + const columns = if (self.process.environ_map.get("COLUMNS")) |value| std.fmt.parseInt(usize, value, 10) catch 80 else 80; + const width = try std.fmt.bufPrint(&width_buffer, "--width={d}", .{@min(72, columns -| 8)}); + try self.style(stdout, &.{ "--border=rounded", "--border-foreground=99", "--padding=1 2", "--margin=0 1", width, content }); + try self.style(stdout, &.{ "--foreground=245", "--margin=1 1", "General guidance, not legal advice. Existing third-party grants remain in force." }); + } else { + try stdout.print("\n=== Repolicense CLI ===\nGeneral guidance, not legal advice. Existing third-party grants remain in force.\n\n--- {s} ---\n{s}\n\n{s}\n", .{ heading, title, body }); } - 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; + pub fn overview(self: *Prompter, stdout: *std.Io.Writer, title: []const u8, body: []const u8, color: []const u8) !void { + try self.beginView(stdout); + try self.style(stdout, &.{ "--foreground=212", "--bold", "REPOLICENSE / compatibility" }); + var content_buffer: [4096]u8 = undefined; + const content = try std.fmt.bufPrint(&content_buffer, "{s}\n\n{s}", .{ title, body }); + var color_buffer: [48]u8 = undefined; + const border = try std.fmt.bufPrint(&color_buffer, "--border-foreground={s}", .{color}); + try self.style(stdout, &.{ "--border=rounded", border, "--padding=0 1", "--width=68", content }); + } + + fn style(self: *const Prompter, stdout: *std.Io.Writer, args: []const []const u8) !void { + try stdout.flush(); + var argv: [24][]const u8 = undefined; + argv[0] = "gum"; + argv[1] = "style"; + @memcpy(argv[2 .. args.len + 2], args); + var child = try std.process.spawn(self.process.io, .{ .argv = argv[0 .. args.len + 2], .stdin = .ignore, .stdout = .inherit, .stderr = .inherit, .create_no_window = false }); + defer child.kill(self.process.io); + if (!(try child.wait(self.process.io)).success()) return error.GumStyleFailed; + } + + pub fn page(self: *const Prompter, stdout: *std.Io.Writer, content: []const u8) !void { + try stdout.flush(); + var child = try std.process.spawn(self.process.io, .{ .argv = &.{ "gum", "pager", "--soft-wrap" }, .stdin = .pipe, .stdout = .inherit, .stderr = .inherit, .create_no_window = false }); + defer child.kill(self.process.io); + var buffer: [4096]u8 = undefined; + var input = child.stdin.?.writer(self.process.io, &buffer); + try input.interface.writeAll(content); + try input.interface.flush(); + child.stdin.?.close(self.process.io); + child.stdin = null; + const term = try child.wait(self.process.io); + if (!term.success() and !(term == .exited and (term.exited == 1 or term.exited == 130))) return error.GumFailed; + } + + pub fn choose(self: *Prompter, stdout: *std.Io.Writer, stdin: *std.Io.Reader, labels: []const []const u8) !?usize { + if (self.usingGum()) { + const result = self.runGumChoose(stdout, labels) catch |err| switch (err) { + error.FileNotFound => { + self.gum_missing = true; + return plainChoose(stdout, stdin, labels); + }, + else => return err, + }; + return switch (result) { + .selected => |i| i, + .cancelled => null, + }; } + return plainChoose(stdout, stdin, labels); } - /// Clear the rendered block and the user input line that followed it. - pub fn clearIncludingInput(self: *Screen, stdout: anytype) !void { - try self.clear(stdout); - // Also clear the user's input line (the line after the rendered block) - try stdout.print("\x1b[1A\x1b[2K", .{}); + pub fn pickMany(self: *Prompter, stdout: *std.Io.Writer, stdin: *std.Io.Reader, labels: []const []const u8, selected: []usize) !?[]const usize { + if (self.usingGum()) { + const result = self.runGumMany(stdout, labels, selected) catch |err| switch (err) { + error.FileNotFound => { + self.gum_missing = true; + return plainMany(stdout, stdin, labels, selected); + }, + else => return err, + }; + return switch (result) { + .selected => |items| items, + .cancelled => null, + }; + } + return plainMany(stdout, stdin, labels, selected); + } + + const GumChoice = union(enum) { selected: usize, cancelled }; + const GumMany = union(enum) { selected: []const usize, cancelled }; + + fn runGumChoose(self: *Prompter, stdout: *std.Io.Writer, labels: []const []const u8) !GumChoice { + try stdout.flush(); + var argv_buffer: [64][]const u8 = undefined; + const flags = [_][]const u8{ "gum", "choose", "--limit=1", "--label-delimiter=", "--header=Choose an option", "--header.foreground=99", "--cursor=❯ ", "--cursor.foreground=212", "--selected.foreground=212", "--padding=0 2", "--height=10" }; + if (labels.len + flags.len > argv_buffer.len) return error.TooManyOptions; + const argv = argv_buffer[0 .. labels.len + flags.len]; + @memcpy(argv[0..flags.len], &flags); + @memcpy(argv[flags.len..], labels); + var output_buffer: [16384]u8 = undefined; + const result = try runGumProcess(self.process, argv, &output_buffer); + if (result.term == .exited and result.term.exited == 0) { + const selection = std.mem.trim(u8, result.output, "\r\n"); + if (selection.len == 0) return .cancelled; + return .{ .selected = findLabel(labels, selection) orelse findDisplayLabel(labels, selection) orelse return error.InvalidGumSelection }; + } + // Gum v2 also uses exit 1 for runtime errors; its stderr is inherited, so classify exit 1 conservatively as cancellation. + if (result.term == .exited and (result.term.exited == 1 or result.term.exited == 130)) return .cancelled; + return error.GumFailed; + } + + fn runGumMany(self: *Prompter, stdout: *std.Io.Writer, labels: []const []const u8, selected: []usize) !GumMany { + try stdout.flush(); + var argv_buffer: [64][]const u8 = undefined; + if (labels.len + 4 > argv_buffer.len) return error.TooManyOptions; + const argv = argv_buffer[0 .. labels.len + 4]; + argv[0] = "gum"; + argv[1] = "filter"; + argv[2] = "--no-limit"; + argv[3] = "--output-delimiter=\n"; + @memcpy(argv[4..], labels); + var output_buffer: [16384]u8 = undefined; + const result = try runGumProcess(self.process, argv, &output_buffer); + if (result.term == .exited and (result.term.exited == 1 or result.term.exited == 130)) return .cancelled; + if (result.term != .exited or result.term.exited != 0) return error.GumFailed; + var count: usize = 0; + var lines = std.mem.tokenizeScalar(u8, result.output, '\n'); + while (lines.next()) |raw| { + const line = std.mem.trimEnd(u8, raw, "\r"); + const index = findLabel(labels, line) orelse findDisplayLabel(labels, line) orelse return error.InvalidGumSelection; + if (!contains(selected[0..count], index)) { + if (count == selected.len) return error.SelectionBufferTooSmall; + selected[count] = index; + count += 1; + } + } + return .{ .selected = selected[0..count] }; } }; + +const GumProcessResult = struct { term: std.process.Child.Term, output: []u8 }; + +fn runGumProcess(process: std.process.Init, argv: []const []const u8, output_buffer: []u8) !GumProcessResult { + var child = try std.process.spawn(process.io, .{ .argv = argv, .stdin = .inherit, .stdout = .pipe, .stderr = .inherit, .create_no_window = false }); + defer child.kill(process.io); + var buffer: [1024]u8 = undefined; + var reader = child.stdout.?.reader(process.io, &buffer); + const count = try reader.interface.readSliceShort(output_buffer); + if (count == output_buffer.len) { + if (reader.interface.takeByte()) |_| { + return error.StreamTooLong; + } else |err| switch (err) { + error.EndOfStream => {}, + else => return err, + } + } + const term = try child.wait(process.io); + return .{ .term = term, .output = output_buffer[0..count] }; +} + +pub fn parseChoice(input: []const u8, labels: []const []const u8) error{InvalidSelection}!?usize { + const value = std.mem.trim(u8, input, &std.ascii.whitespace); + if (value.len == 0) return error.InvalidSelection; + if (std.ascii.eqlIgnoreCase(value, "q") or std.ascii.eqlIgnoreCase(value, "quit") or std.ascii.eqlIgnoreCase(value, "exit")) return actionIndex(labels, "Quit"); + if (std.ascii.eqlIgnoreCase(value, "b") or std.ascii.eqlIgnoreCase(value, "back")) if (actionIndex(labels, "Back")) |i| return i; + if (std.ascii.eqlIgnoreCase(value, "r") or std.ascii.eqlIgnoreCase(value, "reset") or std.ascii.eqlIgnoreCase(value, "restart")) if (actionIndex(labels, "Restart")) |i| return i; + if (value.len == 1 and (value[0] == 'y' or value[0] == 'Y')) if (findDisplayLabelIgnoreCase(labels, "Yes")) |i| return i; + if (value.len == 1 and (value[0] == 'n' or value[0] == 'N')) if (findDisplayLabelIgnoreCase(labels, "No")) |i| return i; + if (findDisplayLabelIgnoreCase(labels, value)) |i| return i; + const number = std.fmt.parseInt(usize, value, 10) catch return error.InvalidSelection; + if (number == 0 or number > labels.len) return error.InvalidSelection; + return number - 1; +} + +pub fn parseMany(input: []const u8, labels: []const []const u8, output: []usize) error{ InvalidSelection, SelectionBufferTooSmall }!?[]const usize { + const value = std.mem.trim(u8, input, &std.ascii.whitespace); + if (value.len == 0 or std.ascii.eqlIgnoreCase(value, "q") or std.ascii.eqlIgnoreCase(value, "quit") or std.ascii.eqlIgnoreCase(value, "exit")) return null; + var count: usize = 0; + var entries = std.mem.tokenizeScalar(u8, value, ','); + while (entries.next()) |raw| { + const entry = std.mem.trim(u8, raw, &std.ascii.whitespace); + if (entry.len == 0) return error.InvalidSelection; + const i = if (findLabel(labels, entry) orelse findDisplayLabel(labels, entry)) |matched| matched else blk: { + const number = std.fmt.parseInt(usize, entry, 10) catch return error.InvalidSelection; + if (number == 0 or number > labels.len) return error.InvalidSelection; + break :blk number - 1; + }; + if (!contains(output[0..count], i)) { + if (count == output.len) return error.SelectionBufferTooSmall; + output[count] = i; + count += 1; + } + } + return output[0..count]; +} + +fn plainChoose(stdout: *std.Io.Writer, stdin: *std.Io.Reader, labels: []const []const u8) !?usize { + while (true) { + for (labels, 0..) |label, i| try stdout.print("{d}. {s}\n", .{ i + 1, label }); + try stdout.writeAll("Select an option: "); + try stdout.flush(); + const line = (try stdin.takeDelimiter('\n')) orelse return null; + const value = std.mem.trim(u8, line, &std.ascii.whitespace); + if (value.len == 0) { + try stdout.writeAll("Invalid selection. Try again.\n"); + continue; + } + if (parseChoice(value, labels)) |choice| { + return choice; + } else |err| switch (err) { + error.InvalidSelection => try stdout.writeAll("Invalid selection. Try again.\n"), + } + } +} + +fn plainMany(stdout: *std.Io.Writer, stdin: *std.Io.Reader, labels: []const []const u8, selected: []usize) !?[]const usize { + while (true) { + for (labels, 0..) |label, i| try stdout.print("{d}. {s}\n", .{ i + 1, label }); + try stdout.writeAll("Select comma-separated options (or q to cancel): "); + try stdout.flush(); + const line = (try stdin.takeDelimiter('\n')) orelse return null; + if (parseMany(line, labels, selected)) |result| { + return result; + } else |err| switch (err) { + error.InvalidSelection => try stdout.writeAll("Invalid selection. Try again.\n"), + error.SelectionBufferTooSmall => return error.SelectionBufferTooSmall, + } + } +} + +fn contains(items: []const usize, needle: usize) bool { + for (items) |item| if (item == needle) return true; + return false; +} +fn findLabel(labels: []const []const u8, needle: []const u8) ?usize { + for (labels, 0..) |label, i| if (std.mem.eql(u8, label, needle)) return i; + return null; +} +fn findDisplayLabel(labels: []const []const u8, needle: []const u8) ?usize { + for (labels, 0..) |label, i| { + const short = if (std.mem.indexOf(u8, label, " — ")) |split| label[0..split] else label; + if (std.mem.eql(u8, short, needle)) return i; + } + return null; +} +fn findDisplayLabelIgnoreCase(labels: []const []const u8, needle: []const u8) ?usize { + for (labels, 0..) |label, i| { + const short = if (std.mem.indexOf(u8, label, " — ")) |split| label[0..split] else label; + if (std.ascii.eqlIgnoreCase(short, needle)) return i; + } + return null; +} +fn actionIndex(labels: []const []const u8, action: []const u8) ?usize { + return findDisplayLabelIgnoreCase(labels, action); +} diff --git a/src/ui_test.zig b/src/ui_test.zig index 6a54149..08c2913 100644 --- a/src/ui_test.zig +++ b/src/ui_test.zig @@ -2,47 +2,45 @@ 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), +const labels = [_][]const u8{ "Yes", "No", "Back", "Restart", "Quit" }; - 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 self.allocator.print(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); +test "choice parser accepts one-based numbers and exact labels" { + try testing.expectEqual(@as(?usize, 0), try ui.parseChoice("1", &labels)); + try testing.expectEqual(@as(?usize, 2), try ui.parseChoice("3", &labels)); + try testing.expectEqual(@as(?usize, 4), try ui.parseChoice("5", &labels)); + try testing.expectEqual(@as(?usize, 1), try ui.parseChoice("No", &labels)); +} - var screen = ui.Screen.init(); +test "choice parser accepts yes, no, and navigation shortcuts" { + try testing.expectEqual(@as(?usize, 0), try ui.parseChoice("y", &labels)); + try testing.expectEqual(@as(?usize, 1), try ui.parseChoice("N", &labels)); + try testing.expectEqual(@as(?usize, 2), try ui.parseChoice("b", &labels)); + try testing.expectEqual(@as(?usize, 3), try ui.parseChoice("reset", &labels)); + try testing.expectEqual(@as(?usize, 4), try ui.parseChoice("exit", &labels)); + try testing.expectError(error.InvalidSelection, ui.parseChoice("y", labels[2..])); +} - // 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); +test "choice parser rejects blank, invalid, and out-of-range selections" { + try testing.expectError(error.InvalidSelection, ui.parseChoice("", &labels)); + try testing.expectError(error.InvalidSelection, ui.parseChoice("0", &labels)); + try testing.expectError(error.InvalidSelection, ui.parseChoice("6", &labels)); + try testing.expectError(error.InvalidSelection, ui.parseChoice("maybe", &labels)); +} - try testing.expect(std.mem.indexOf(u8, out1, "first line\n") != null); +test "multiple parser maps labels and numbers while removing duplicates" { + const choices = [_][]const u8{ "MIT", "Apache-2.0", "MPL-2.0" }; + var output: [3]usize = undefined; + const result = try ui.parseMany("MPL-2.0, 2, MPL-2.0", &choices, &output); + try testing.expectEqualSlices(usize, &.{ 2, 1 }, result.?); +} - // 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); +test "multiple parser accepts cancellation and rejects invalid bounds" { + var output: [4]usize = undefined; + try testing.expectEqual(@as(?[]const usize, null), try ui.parseMany("q", &labels, &output)); + try testing.expectError(error.InvalidSelection, ui.parseMany("1,6", &labels, &output)); +} - 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); +test "multiple parser rejects selections that exceed output capacity" { + var output: [1]usize = undefined; + try testing.expectError(error.SelectionBufferTooSmall, ui.parseMany("1,2", &labels, &output)); }