diff --git a/AGENTS.md b/AGENTS.md index f9bcc27a..9d97c7ab 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -59,6 +59,7 @@ lets publish-docs # deploy docs site - Fixtures in matching `tests//` folder, use `lets.yaml` unless variant needed - Bats tests use `run` + `assert_success`/`assert_line` pattern - Run at least `go test ./...` before considering work complete; `lets test-bats` for CLI-path changes +- **Golden tests** — `internal/cmd/testdata/*` are snapshot of the rendered help and error output. If you change anything that affects help or error rendering (flags, styles, section titles, error messages), regenerate them with `go test ./internal/cmd/ -run -update` (or `lets test-unit --update-golden` in Docker), then commit the updated `.golden` files. If you add a new rendering behaviour (new section, new error type, new command layout), add a corresponding golden test in `internal/cmd/help_golden_test.go` with a fixture YAML in `internal/cmd/testdata/fixtures/` if needed, then run with `-update` to create the golden file. - Commits: short imperative subjects (`Add ...`, `Fix ...`, `Use ...`), explain non-obvious context in body - **Changelog workflow**: add entries to the `Unreleased` section in `docs/docs/changelog.md` with each commit/PR. At release time, rename `Unreleased` to the new tag version - Do not commit `lets.my.yaml`, generated binaries, `.lets/`, `coverage.out`, or `node_modules` diff --git a/docs/docs/changelog.md b/docs/docs/changelog.md index e6880d93..e369a84b 100644 --- a/docs/docs/changelog.md +++ b/docs/docs/changelog.md @@ -6,7 +6,9 @@ title: Changelog ## [Unreleased](https://github.com/lets-cli/lets/releases/tag/v0.0.X) * `[Fixed]` Make root and `self` help paths delegate through Cobra help handling, and allow `--version` without requiring config. +* `[Changed]` Add breathing room between the `USAGE` heading and its usage block in help output. * `[Refactoring]` Use Go 1.26 `errors.AsType` for type-safe error unwrapping. +* `[Testing]` Add golden-file tests for help and error rendering in `internal/cmd`, replacing bats format checks with snapshot comparisons. ## [0.0.61](https://github.com/lets-cli/lets/releases/tag/v0.0.61) diff --git a/docs/docs/development.md b/docs/docs/development.md index 0e192a5a..13f6ad1d 100644 --- a/docs/docs/development.md +++ b/docs/docs/development.md @@ -62,6 +62,24 @@ lets test-bats lets test-bats global_env.bats ``` +### Golden tests + +Help and error rendering is verified with golden-file (snapshot) tests in `internal/cmd/`. Each test renders output into a buffer and compares it against a `.golden` file in `internal/cmd/testdata/`. + +Run golden tests: + +```bash +go test ./internal/cmd/ -run "TestHelpGolden|TestCommandHelpGolden|TestErrorGolden" +``` + +When help rendering changes intentionally (e.g. new flags, updated styles), regenerate the golden files: + +```bash +go test ./internal/cmd/ -run "TestHelpGolden|TestCommandHelpGolden|TestErrorGolden" -update +``` + +Review the diff of `internal/cmd/testdata/*.golden` before committing to confirm the rendered output looks correct. + ## Release To release a new version: diff --git a/go.mod b/go.mod index 41d0eb7a..0a79fde2 100644 --- a/go.mod +++ b/go.mod @@ -5,10 +5,17 @@ go 1.26 toolchain go1.26.0 require ( + charm.land/lipgloss/v2 v2.0.1 + github.com/charmbracelet/colorprofile v0.4.2 + github.com/charmbracelet/x/ansi v0.11.6 + github.com/charmbracelet/x/exp/charmtone v0.0.0-20250603201427-c31516f43444 + github.com/charmbracelet/x/exp/golden v0.0.0-20260608090822-c3ad58c6c9e5 + github.com/charmbracelet/x/term v0.2.2 github.com/codeclysm/extract v2.2.0+incompatible github.com/coreos/go-semver v0.3.1 github.com/fatih/color v1.16.0 github.com/kindermax/docopt.go v0.8.0 + github.com/lets-cli/fang v0.1.0 github.com/mattn/go-isatty v0.0.20 github.com/odvcencio/gotreesitter v0.12.1 github.com/pkg/errors v0.9.1 @@ -16,25 +23,38 @@ require ( github.com/spf13/cobra v1.10.2 github.com/tliron/commonlog v0.2.8 github.com/tliron/glsp v0.2.2 - golang.org/x/sync v0.3.0 + golang.org/x/sync v0.19.0 ) require ( github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect + github.com/aymanbagabas/go-udiff v0.4.1 // indirect + github.com/charmbracelet/ultraviolet v0.0.0-20260205113103-524a6607adb8 // indirect + github.com/charmbracelet/x/termios v0.1.1 // indirect + github.com/charmbracelet/x/windows v0.2.2 // indirect + github.com/clipperhouse/displaywidth v0.11.0 // indirect + github.com/clipperhouse/uax29/v2 v2.7.0 // indirect github.com/gorilla/websocket v1.5.1 // indirect github.com/iancoleman/strcase v0.3.0 // indirect - github.com/lucasb-eyer/go-colorful v1.2.0 // indirect + github.com/lucasb-eyer/go-colorful v1.3.0 // indirect github.com/mattn/go-colorable v0.1.13 // indirect - github.com/mattn/go-runewidth v0.0.14 // indirect + github.com/mattn/go-runewidth v0.0.20 // indirect + github.com/muesli/cancelreader v0.2.2 // indirect + github.com/muesli/mango v0.1.0 // indirect + github.com/muesli/mango-cobra v1.2.0 // indirect + github.com/muesli/mango-pflag v0.1.0 // indirect + github.com/muesli/roff v0.1.0 // indirect github.com/muesli/termenv v0.15.2 // indirect github.com/petermattis/goid v0.0.0-20180202154549-b0b1615b78e5 // indirect - github.com/rivo/uniseg v0.2.0 // indirect + github.com/rivo/uniseg v0.4.7 // indirect github.com/sasha-s/go-deadlock v0.3.1 // indirect github.com/sourcegraph/jsonrpc2 v0.2.0 // indirect github.com/tliron/kutil v0.3.11 // indirect + github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect golang.org/x/crypto v0.15.0 // indirect golang.org/x/net v0.17.0 // indirect golang.org/x/term v0.14.0 // indirect + golang.org/x/text v0.24.0 // indirect ) require ( @@ -45,8 +65,8 @@ require ( github.com/juju/testing v0.0.0-20201216035041-2be42bba85f3 // indirect github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 github.com/lithammer/dedent v1.1.0 - github.com/spf13/pflag v1.0.9 // indirect - golang.org/x/sys v0.14.0 // indirect + github.com/spf13/pflag v1.0.9 + golang.org/x/sys v0.42.0 // indirect gopkg.in/check.v1 v1.0.0-20200902074654-038fdea0a05b // indirect gopkg.in/yaml.v3 v3.0.1 ) diff --git a/go.sum b/go.sum index d87ef75c..ab503bb7 100644 --- a/go.sum +++ b/go.sum @@ -1,5 +1,29 @@ +charm.land/lipgloss/v2 v2.0.1 h1:6Xzrn49+Py1Um5q/wZG1gWgER2+7dUyZ9XMEufqPSys= +charm.land/lipgloss/v2 v2.0.1/go.mod h1:KjPle2Qd3YmvP1KL5OMHiHysGcNwq6u83MUjYkFvEkM= github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= +github.com/aymanbagabas/go-udiff v0.4.1 h1:OEIrQ8maEeDBXQDoGCbbTTXYJMYRCRO1fnodZ12Gv5o= +github.com/aymanbagabas/go-udiff v0.4.1/go.mod h1:0L9PGwj20lrtmEMeyw4WKJ/TMyDtvAoK9bf2u/mNo3w= +github.com/charmbracelet/colorprofile v0.4.2 h1:BdSNuMjRbotnxHSfxy+PCSa4xAmz7szw70ktAtWRYrY= +github.com/charmbracelet/colorprofile v0.4.2/go.mod h1:0rTi81QpwDElInthtrQ6Ni7cG0sDtwAd4C4le060fT8= +github.com/charmbracelet/ultraviolet v0.0.0-20260205113103-524a6607adb8 h1:eyFRbAmexyt43hVfeyBofiGSEmJ7krjLOYt/9CF5NKA= +github.com/charmbracelet/ultraviolet v0.0.0-20260205113103-524a6607adb8/go.mod h1:SQpCTRNBtzJkwku5ye4S3HEuthAlGy2n9VXZnWkEW98= +github.com/charmbracelet/x/ansi v0.11.6 h1:GhV21SiDz/45W9AnV2R61xZMRri5NlLnl6CVF7ihZW8= +github.com/charmbracelet/x/ansi v0.11.6/go.mod h1:2JNYLgQUsyqaiLovhU2Rv/pb8r6ydXKS3NIttu3VGZQ= +github.com/charmbracelet/x/exp/charmtone v0.0.0-20250603201427-c31516f43444 h1:IJDiTgVE56gkAGfq0lBEloWgkXMk4hl/bmuPoicI4R0= +github.com/charmbracelet/x/exp/charmtone v0.0.0-20250603201427-c31516f43444/go.mod h1:T9jr8CzFpjhFVHjNjKwbAD7KwBNyFnj2pntAO7F2zw0= +github.com/charmbracelet/x/exp/golden v0.0.0-20260608090822-c3ad58c6c9e5 h1:RXY6LeySQDb2yeimor4XUcS/PBj7GyatNIz2ng2Zx8M= +github.com/charmbracelet/x/exp/golden v0.0.0-20260608090822-c3ad58c6c9e5/go.mod h1:6fMpcW6iwN/kX+xJ52eqVWsDiBTe0UJD24JLoHFe+P0= +github.com/charmbracelet/x/term v0.2.2 h1:xVRT/S2ZcKdhhOuSP4t5cLi5o+JxklsoEObBSgfgZRk= +github.com/charmbracelet/x/term v0.2.2/go.mod h1:kF8CY5RddLWrsgVwpw4kAa6TESp6EB5y3uxGLeCqzAI= +github.com/charmbracelet/x/termios v0.1.1 h1:o3Q2bT8eqzGnGPOYheoYS8eEleT5ZVNYNy8JawjaNZY= +github.com/charmbracelet/x/termios v0.1.1/go.mod h1:rB7fnv1TgOPOyyKRJ9o+AsTU/vK5WHJ2ivHeut/Pcwo= +github.com/charmbracelet/x/windows v0.2.2 h1:IofanmuvaxnKHuV04sC0eBy/smG6kIKrWG2/jYn2GuM= +github.com/charmbracelet/x/windows v0.2.2/go.mod h1:/8XtdKZzedat74NQFn0NGlGL4soHB0YQZrETF96h75k= +github.com/clipperhouse/displaywidth v0.11.0 h1:lBc6kY44VFw+TDx4I8opi/EtL9m20WSEFgwIwO+UVM8= +github.com/clipperhouse/displaywidth v0.11.0/go.mod h1:bkrFNkf81G8HyVqmKGxsPufD3JhNl3dSqnGhOoSD/o0= +github.com/clipperhouse/uax29/v2 v2.7.0 h1:+gs4oBZ2gPfVrKPthwbMzWZDaAFPGYK72F0NJv2v7Vk= +github.com/clipperhouse/uax29/v2 v2.7.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM= github.com/codeclysm/extract v2.2.0+incompatible h1:q3wyckoA30bhUSiwdQezMqVhwd8+WGE64/GL//LtUhI= github.com/codeclysm/extract v2.2.0+incompatible/go.mod h1:2nhFMPHiU9At61hz+12bfrlpXSUrOnK+wR+KlGO4Uks= github.com/coreos/go-semver v0.3.1 h1:yi21YpKnrx1gt5R+la8n5WgS0kCrsPp33dmEyHReZr4= @@ -56,10 +80,12 @@ github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/lets-cli/fang v0.1.0 h1:lGNVcqodnwu1W/iIRI7BNdblcWZdaZ1ms7/D15U0O3U= +github.com/lets-cli/fang v0.1.0/go.mod h1:ReK1Yg4pNI2OOCSvDS3LBIV+cgjv/KwdEhz7JXz94M4= github.com/lithammer/dedent v1.1.0 h1:VNzHMVCBNG1j0fh3OrsFRkVUwStdDArbgBWoPAffktY= github.com/lithammer/dedent v1.1.0/go.mod h1:jrXYCQtgg0nJiN+StA2KgR7w6CiQNv9Fd/Z9BP0jIOc= -github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY= -github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= +github.com/lucasb-eyer/go-colorful v1.3.0 h1:2/yBRLdWBZKrf7gB40FoiKfAWYQ0lqNcbuQwVHXptag= +github.com/lucasb-eyer/go-colorful v1.3.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= github.com/lunixbochs/vtclean v0.0.0-20160125035106-4fbf7632a2c6/go.mod h1:pHhQNgMf3btfWnGBVipUOjRYhoOsdGqdm/+2c2E2WMI= github.com/masterzen/azure-sdk-for-go v3.2.0-beta.0.20161014135628-ee4f0065d00c+incompatible/go.mod h1:mf8fjOu33zCqxUjuiU3I8S1lJMyEAlH+0F2+M5xl3hE= github.com/masterzen/simplexml v0.0.0-20160608183007-4572e39b1ab9/go.mod h1:kCEbxUJlNDEBNbdQMkPSp6yaKcRXVI6f4ddk8Riv4bc= @@ -72,8 +98,18 @@ github.com/mattn/go-isatty v0.0.0-20160806122752-66b8e73f3f5c/go.mod h1:M+lRXTBq github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= -github.com/mattn/go-runewidth v0.0.14 h1:+xnbZSEeDbOIg5/mE6JF0w6n9duR1l3/WmbinWVwUuU= -github.com/mattn/go-runewidth v0.0.14/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= +github.com/mattn/go-runewidth v0.0.20 h1:WcT52H91ZUAwy8+HUkdM3THM6gXqXuLJi9O3rjcQQaQ= +github.com/mattn/go-runewidth v0.0.20/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs= +github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA= +github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo= +github.com/muesli/mango v0.1.0 h1:DZQK45d2gGbql1arsYA4vfg4d7I9Hfx5rX/GCmzsAvI= +github.com/muesli/mango v0.1.0/go.mod h1:5XFpbC8jY5UUv89YQciiXNlbi+iJgt29VDC5xbzrLL4= +github.com/muesli/mango-cobra v1.2.0 h1:DQvjzAM0PMZr85Iv9LIMaYISpTOliMEg+uMFtNbYvWg= +github.com/muesli/mango-cobra v1.2.0/go.mod h1:vMJL54QytZAJhCT13LPVDfkvCUJ5/4jNUKF/8NC2UjA= +github.com/muesli/mango-pflag v0.1.0 h1:UADqbYgpUyRoBja3g6LUL+3LErjpsOwaC9ywvBWe7Sg= +github.com/muesli/mango-pflag v0.1.0/go.mod h1:YEQomTxaCUp8PrbhFh10UfbhbQrM/xJ4i2PB8VTLLW0= +github.com/muesli/roff v0.1.0 h1:YD0lalCotmYuF5HhZliKWlIx7IEhiXeSfq7hNjFqGF8= +github.com/muesli/roff v0.1.0/go.mod h1:pjAHQM9hdUUwm/krAfrLGgJkXJ+YuhtsfZ42kieB2Ig= github.com/muesli/termenv v0.15.2 h1:GohcuySI0QmI3wN8Ok9PtKGkgkFIk7y6Vpb5PvrY+Wo= github.com/muesli/termenv v0.15.2/go.mod h1:Epx+iuz8sNs7mNKhxzH4fWXGNpZwUaJKRS1noLXviQ8= github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e h1:fD57ERR4JtEqsWbfPhv4DMiApHyliiK5xCTNVSPiaAs= @@ -87,8 +123,8 @@ github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= -github.com/rivo/uniseg v0.2.0 h1:S1pD9weZBuJdFmowNwbpi7BJ8TNftyUImj/0WQi72jY= -github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= +github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= +github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= github.com/sasha-s/go-deadlock v0.3.1 h1:sqv7fDNShgjcaxkO0JNcOAlr8B9+cV5Ey/OB71efZx0= github.com/sasha-s/go-deadlock v0.3.1/go.mod h1:F73l+cr82YSh10GxyRI6qZiCgK64VaZjwesgfQ1/iLM= @@ -101,14 +137,17 @@ github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiT github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY= github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= -github.com/stretchr/testify v1.7.0 h1:nwc3DEeHmmLAfoZucVR881uASk0Mfjw8xYJ99tb5CcY= github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= +github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= github.com/tliron/commonlog v0.2.8 h1:vpKrEsZX4nlneC9673pXpeKqv3cFLxwpzNEZF1qiaQQ= github.com/tliron/commonlog v0.2.8/go.mod h1:HgQZrJEuiKLLRvUixtPWGcmTmWWtKkCtywF6x9X5Spw= github.com/tliron/glsp v0.2.2 h1:IKPfwpE8Lu8yB6Dayta+IyRMAbTVunudeauEgjXBt+c= github.com/tliron/glsp v0.2.2/go.mod h1:GMVWDNeODxHzmDPvYbYTCs7yHVaEATfYtXiYJ9w1nBg= github.com/tliron/kutil v0.3.11 h1:kongR0dhrrn9FR/3QRFoUfQe27t78/xQvrU9aXIy5bk= github.com/tliron/kutil v0.3.11/go.mod h1:4IqOAAdpJuDxYbJxMv4nL8LSH0mPofSrdwIv8u99PDc= +github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= +github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= golang.org/x/crypto v0.0.0-20180214000028-650f4a345ab4/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= @@ -116,24 +155,28 @@ golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPh golang.org/x/crypto v0.0.0-20200820211705-5c72a883971a/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= golang.org/x/crypto v0.15.0 h1:frVn1TEaCEaZcn3Tmd7Y2b5KKPaZ+I32Q2OA3kYp5TA= golang.org/x/crypto v0.15.0/go.mod h1:4ChreQoLWfG3xLDer1WdlH5NdlQ3+mwnQq1YTKY+72g= +golang.org/x/exp v0.0.0-20231006140011-7918f672742d h1:jtJma62tbqLibJ5sFQz8bKtEM8rJBtfilJ2qTU199MI= +golang.org/x/exp v0.0.0-20231006140011-7918f672742d/go.mod h1:ldy0pHrwJyGW56pPQzzkH36rKxoZW1tw7ZJpeKx+hdo= golang.org/x/net v0.0.0-20180406214816-61147c48b25b/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.0.0-20200904194848-62affa334b73/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA= golang.org/x/net v0.17.0 h1:pVaXccu2ozPjCXewfr1S7xza/zcXTity9cCdXQYSjIM= golang.org/x/net v0.17.0/go.mod h1:NxSsAGuq816PNPmqtQdLE42eU2Fs7NoRIZrHJAlaCOE= -golang.org/x/sync v0.3.0 h1:ftCYgMx6zT/asHUrPw8BLLscYtGznsLAnjq5RH9P66E= -golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y= +golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4= +golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.14.0 h1:Vz7Qs629MkJkGyHxUlRHizWJRG2j8fbQKjELVSNhy7Q= -golang.org/x/sys v0.14.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo= +golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/term v0.14.0 h1:LGK9IlZ8T9jvdy6cTdfKUCltatMFOehAQo9SRC46UQ8= golang.org/x/term v0.14.0/go.mod h1:TySc+nGkYR6qt8km8wUhuFRTVSMIX3XPR58y2lC8vww= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.24.0 h1:dd5Bzh4yt5KYA8f9CJHCP4FB4D51c2c6JvN37xJJkJ0= +golang.org/x/text v0.24.0/go.mod h1:L8rBsPeo2pSS+xqN0d5u2ikmjtmoJbDBT1b7nHvFCdU= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20160105164936-4f90aeace3a2/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20200902074654-038fdea0a05b h1:QRR6H1YWRnHb4Y/HeNFCTJLFVxaq6wH4YuVdsUOr75U= diff --git a/internal/cli/cli.go b/internal/cli/cli.go index 4f54cbbe..a747e2d8 100644 --- a/internal/cli/cli.go +++ b/internal/cli/cli.go @@ -10,13 +10,14 @@ import ( "syscall" "time" + "github.com/lets-cli/fang" "github.com/lets-cli/lets/internal/cmd" "github.com/lets-cli/lets/internal/config" "github.com/lets-cli/lets/internal/env" - "github.com/lets-cli/lets/internal/executor" "github.com/lets-cli/lets/internal/logging" "github.com/lets-cli/lets/internal/set" "github.com/lets-cli/lets/internal/settings" + "github.com/lets-cli/lets/internal/theme" "github.com/lets-cli/lets/internal/upgrade" "github.com/lets-cli/lets/internal/upgrade/registry" "github.com/lets-cli/lets/internal/workdir" @@ -106,16 +107,14 @@ func Main(version string, buildDate string) int { updateCh, cancelUpdateCheck := maybeStartUpdateCheck(ctx, version, command, appSettings) defer cancelUpdateCheck() - if err := rootCmd.ExecuteContext(ctx); err != nil { - if depErr, ok := errors.AsType[*executor.DependencyError](err); ok { - log.Errorf("%s", depErr.TreeMessage()) - log.Errorf("%s", depErr.FailureMessage()) - - return getExitCode(err, 1) - } - - log.Errorf("%s", err.Error()) - + if err := fang.Execute( + ctx, + rootCmd, + fang.WithVersion(rootCmd.Version), + fang.WithColorSchemeFunc(theme.DefaultColorScheme), + fang.WithErrorHandler(cmd.ErrorHandler), + fang.WithHelpRenderer(cmd.HelpRenderer), + ); err != nil { return getExitCode(err, 1) } diff --git a/internal/cmd/error.go b/internal/cmd/error.go new file mode 100644 index 00000000..5768fe25 --- /dev/null +++ b/internal/cmd/error.go @@ -0,0 +1,182 @@ +package cmd + +import ( + "errors" + "fmt" + "io" + "strings" + + "charm.land/lipgloss/v2" + "github.com/charmbracelet/x/term" + "github.com/lets-cli/fang" + "github.com/lets-cli/lets/internal/executor" +) + +// ErrorHandler renders command execution errors using lets' Fang-based style. +func ErrorHandler(w io.Writer, styles fang.Styles, err error) { + newErrorRenderer(w, styles, err).Render() +} + +// errorRenderer owns the full error rendering flow for one error value. +type errorRenderer struct { + out errorOutput + styles fang.Styles + err error +} + +// newErrorRenderer wires an error, styles, and output helper together. +func newErrorRenderer(w io.Writer, styles fang.Styles, err error) errorRenderer { + return errorRenderer{out: newErrorOutput(w, styles), styles: styles, err: err} +} + +// errorOutput hides low-level writes and common error-specific styled lines. +type errorOutput struct { + w io.Writer + styles fang.Styles +} + +// newErrorOutput creates the error output adapter. +func newErrorOutput(w io.Writer, styles fang.Styles) errorOutput { + return errorOutput{w: w, styles: styles} +} + +// writeln writes one error output line and intentionally ignores write errors. +func (o errorOutput) writeln(v ...any) { + _, _ = fmt.Fprintln(o.w, v...) +} + +// blank writes one blank error output line. +func (o errorOutput) blank() { + o.writeln() +} + +// header writes the styled error heading. +func (o errorOutput) header() { + o.writeln(o.styles.ErrorHeader.String()) +} + +// commandTreeTitle writes the dependency tree section heading. +func (o errorOutput) commandTreeTitle() { + title := o.styles.Title.Margin(0, 0).MarginLeft(2).Padding(0, 0) + o.writeln(title.Render("command tree:")) +} + +// Render prints the complete error view or a plain error for non-terminal output. +func (r errorRenderer) Render() { + if r.shouldRenderPlain() { + r.out.writeln(r.err.Error()) + return + } + + errorText := r.styles.ErrorText + + r.out.header() + r.renderMessage(errorText) + r.out.blank() + + if depErr := r.dependencyError(); depErr != nil { + r.renderDependencyTree(depErr) + r.out.blank() + + return + } + + if isUsageError(r.err) { + r.renderUsageHint(errorText) + } +} + +// shouldRenderPlain reports whether styled terminal output should be skipped. +func (r errorRenderer) shouldRenderPlain() bool { + w, ok := r.out.w.(term.File) + return ok && !term.IsTerminal(w.Fd()) +} + +// dependencyError extracts dependency chain context when the error carries it. +func (r errorRenderer) dependencyError() *executor.DependencyError { + var depErr *executor.DependencyError + if errors.As(r.err, &depErr) { + return depErr + } + + return nil +} + +// renderMessage prints the primary error and, when present, its split cause. +func (r errorRenderer) renderMessage(style lipgloss.Style) { + message, cause := splitExecuteError(r.err) + r.out.writeln(style.Render(message + ".")) + + if cause != "" { + r.out.writeln(style.UnsetTransform().Render(capitalizeExitStatus(cause) + ".")) + } +} + +// renderUsageHint prints the compact help suggestion for usage errors. +func (r errorRenderer) renderUsageHint(errorText lipgloss.Style) { + r.out.writeln(lipgloss.JoinHorizontal( + lipgloss.Left, + errorText.UnsetWidth().Render("Try"), + " ", + r.styles.Program.Flag.Render("--help"), + " for usage.", + )) + r.out.blank() +} + +// renderDependencyTree prints the failed command chain for dependency errors. +func (r errorRenderer) renderDependencyTree(depErr *executor.DependencyError) { + joint := r.styles.Program.DimmedArgument.Render("└─ ") + failed := r.styles.ErrorHeader.UnsetMargins().UnsetString().Render("<-- failed here") + + r.out.commandTreeTitle() + + for i, name := range depErr.Chain { + line := strings.Repeat(" ", i+2) + joint + r.styles.Program.Command.Render(name) + if i == len(depErr.Chain)-1 { + line += " " + failed + } + + r.out.writeln(line) + } +} + +// capitalizeExitStatus normalizes Go command exit messages for user-facing output. +func capitalizeExitStatus(text string) string { + if after, ok := strings.CutPrefix(text, "exit status"); ok { + return "Exit status" + after + } + + return text +} + +// splitExecuteError separates an executor error's message from its process cause. +func splitExecuteError(err error) (string, string) { + var executeErr *executor.ExecuteError + if !errors.As(err, &executeErr) { + return err.Error(), "" + } + + cause := executeErr.Cause().Error() + message := strings.TrimSuffix(err.Error(), ": "+cause) + + return message, cause +} + +// isUsageError reports whether an error should include a help hint. +func isUsageError(err error) bool { + s := err.Error() + for _, prefix := range []string{ + "flag needs an argument:", + "unknown flag:", + "unknown shorthand flag:", + "unknown command", + "invalid argument", + } { + if strings.HasPrefix(s, prefix) { + return true + } + } + + return false +} diff --git a/internal/cmd/help.go b/internal/cmd/help.go new file mode 100644 index 00000000..c963e040 --- /dev/null +++ b/internal/cmd/help.go @@ -0,0 +1,655 @@ +package cmd + +import ( + "cmp" + "encoding/json" + "fmt" + "io" + "reflect" + "regexp" + "slices" + "strings" + + "charm.land/lipgloss/v2" + "github.com/charmbracelet/colorprofile" + "github.com/charmbracelet/x/ansi" + "github.com/lets-cli/fang" + "github.com/lets-cli/lets/internal/docopt" + "github.com/spf13/cobra" + "github.com/spf13/pflag" +) + +// helpItem is a pre-rendered key/description row in a help section. +type helpItem struct { + key string + help string +} + +// commandHelpItem carries command metadata needed for grouping and row rendering. +type commandHelpItem struct { + name string + subgroup string + key string + help string +} + +var ( + optionalArgsRe = regexp.MustCompile(`(\[.*\])`) + paddedLeft2 = lipgloss.NewStyle().PaddingLeft(2) +) + +// HelpRenderer renders Cobra help using lets' Fang-based layout. +// +// It coordinates the full output, for example: +// +// USAGE +// lets build [] [--flags] +// +// COMMANDS +// test Run all tests +// +// OPTIONS +// --help, -h Show help +func HelpRenderer(cmd *cobra.Command, ctx fang.HelpContext) { + newHelpRenderer(cmd, ctx).Render() +} + +// helpRenderer owns the full help rendering flow for one command. +type helpRenderer struct { + cmd *cobra.Command + ctx fang.HelpContext + out helpOutput +} + +// newHelpRenderer wires a command, Fang context, and output helper together. +func newHelpRenderer(cmd *cobra.Command, ctx fang.HelpContext) helpRenderer { + return helpRenderer{cmd: cmd, ctx: ctx, out: newHelpOutput(ctx)} +} + +// helpOutput hides low-level writes and common help-specific styled lines. +type helpOutput struct { + w io.Writer + styles fang.Styles +} + +// newHelpOutput creates the help output adapter from Fang's help context. +func newHelpOutput(ctx fang.HelpContext) helpOutput { + return helpOutput{w: ctx.Writer, styles: ctx.Styles} +} + +// writeln writes one help output line and intentionally ignores write errors. +func (o helpOutput) writeln(v ...any) { + _, _ = fmt.Fprintln(o.w, v...) +} + +// blank writes one blank help output line. +func (o helpOutput) blank() { + o.writeln() +} + +// sectionTitle writes a top-level help section title. +func (o helpOutput) sectionTitle(title string) { + o.writeln(compactTitleStyle(o.styles).Render(title)) +} + +// subgroupTitle writes a nested command subgroup title. +func (o helpOutput) subgroupTitle(title string) { + o.writeln(subgroupTitleStyle(o.styles).Render(title)) +} + +// Render prints the complete help view for the renderer's command. +// +// It owns the section order: description, usage/examples, command groups, options. +func (r helpRenderer) Render() { + r.renderLongShort(cmp.Or(r.cmd.Long, r.cmd.Short)) + r.renderUsageAndExamples() + + content := r.collectContent() + r.renderCommandGroups(content) + r.renderOptions(content) + + r.out.blank() +} + +// helpContent is the collected command/options model used by the render phase. +type helpContent struct { + groups map[string]string + groupKeys []string + commands map[string][]commandHelpItem + options []helpItem + optionsTitle string + hasSubgroups bool + space int +} + +// renderLongShort prints the command description before structured sections. +// +// Example output: +// +// Run build tasks defined in lets.yaml. +func (r helpRenderer) renderLongShort(longShort string) { + if longShort == "" { + return + } + + longShort = strings.TrimRight(longShort, "\n") + + r.out.blank() + r.out.writeln(r.ctx.Styles.Text.Width(r.ctx.Width).Render(longShort)) +} + +// renderUsageAndExamples prints usage and example code blocks. +// +// Example output: +// +// USAGE +// lets release [--flags] +// +// EXAMPLES +// lets release 1.2.3 --message "Release" +func (r helpRenderer) renderUsageAndExamples() { + usage := styleHelpUsage(r.cmd, r.ctx.Styles.Codeblock.Program, true) + examples := fang.StyleExamples(r.cmd, r.ctx.Styles) + blockStyle := compactCodeBlockStyle(r.ctx, append([]string{usage}, examples...)...) + + r.out.sectionTitle("usage") + r.out.blank() + r.out.writeln(blockStyle.Render(usage)) + + if len(examples) > 0 { + cw := blockStyle.GetWidth() - blockStyle.GetHorizontalPadding() + + r.out.sectionTitle("examples") + + for i, example := range examples { + if lipgloss.Width(example) > cw { + examples[i] = ansi.Truncate(example, cw, "…") + } + } + + r.out.writeln(blockStyle.Render(strings.Join(examples, "\n"))) + } +} + +// collectContent builds the command and option model before rendering rows. +func (r helpRenderer) collectContent() helpContent { + groups, groupKeys := r.groups() + commands := r.commandGroups() + options, optionsTitle := r.optionItems() + hasSubgroups := hasMultipleSubgroups(commands) + space := r.helpSpace(commands, options, hasSubgroups) + + return helpContent{ + groups: groups, + groupKeys: groupKeys, + commands: commands, + options: options, + optionsTitle: optionsTitle, + hasSubgroups: hasSubgroups, + space: space, + } +} + +// renderCommandGroups prints all non-empty command groups in Cobra group order. +// +// Example output: +// +// COMMANDS +// build Build lets +// test Run all tests +// +// INTERNAL COMMANDS +// completion Generate completion scripts +func (r helpRenderer) renderCommandGroups(content helpContent) { + for _, groupID := range content.groupKeys { + items := content.commands[groupID] + if len(items) == 0 { + continue + } + + r.renderCommandGroup(content.space, content.groups[groupID], items, content.hasSubgroups) + } +} + +// renderOptions prints flags or docopt options when the command exposes any. +// +// Example output: +// +// OPTIONS +// Set version +// --message=, -m Release message +func (r helpRenderer) renderOptions(content helpContent) { + if len(content.options) > 0 { + r.renderHelpGroup(content.space, content.optionsTitle, content.options) + } +} + +// compactTitleStyle removes title spacing that Fang's defaults add for broader layouts. +func compactTitleStyle(styles fang.Styles) lipgloss.Style { + return styles.Title.Margin(0, 0).PaddingBottom(0) +} + +// subgroupTitleStyle derives the nested subgroup title from the compact title style. +func subgroupTitleStyle(styles fang.Styles) lipgloss.Style { + return compactTitleStyle(styles).PaddingTop(0).PaddingLeft(2) +} + +// compactCodeBlockStyle sizes code blocks to their content while respecting terminal width. +func compactCodeBlockStyle(ctx fang.HelpContext, blocks ...string) lipgloss.Style { + base := ctx.Styles.Codeblock.Base.Padding(1, 2) + padding := base.GetHorizontalPadding() + + blockWidth := 0 + for _, block := range blocks { + blockWidth = max(blockWidth, lipgloss.Width(block)) + } + + blockWidth = min(ctx.Width-padding, blockWidth+padding) + blockStyle := base.Width(blockWidth) + + if ctx.Writer.Profile <= colorprofile.Ascii || reflect.DeepEqual(blockStyle.GetBackground(), lipgloss.NoColor{}) { + blockStyle = blockStyle.PaddingTop(0).PaddingBottom(0) + } + + return blockStyle +} + +// styleHelpUsage renders custom docopt usage annotations or falls back to Fang's usage renderer. +func styleHelpUsage(cmd *cobra.Command, styles fang.Program, complete bool) string { + usage := cmd.Annotations[annotationHelpUsage] + if usage == "" { + return fang.StyleUsage(cmd, styles, complete) + } + + var lines []string + + for line := range strings.SplitSeq(usage, "\n") { + line = strings.TrimSpace(line) + if line == "" { + continue + } + + if complete { + line = completeHelpUsage(cmd, line) + } + + lines = append(lines, styleUsageText(cmd, styles, line, complete)) + } + + if len(lines) == 0 { + return fang.StyleUsage(cmd, styles, complete) + } + + return strings.Join(lines, "\n") +} + +// completeHelpUsage prefixes custom usage lines with the parent command path when needed. +func completeHelpUsage(cmd *cobra.Command, usage string) string { + parent := cmd.Parent() + if parent == nil { + return usage + } + + parentPath := parent.CommandPath() + if parentPath == "" || strings.HasPrefix(usage, parentPath+" ") { + return usage + } + + return parentPath + " " + usage +} + +// styleUsageText applies Fang program styles to one normalized usage line. +func styleUsageText(cmd *cobra.Command, styles fang.Program, usage string, complete bool) string { + hasArgs := strings.Contains(usage, "[args]") + hasFlags := strings.Contains(usage, "[flags]") || + strings.Contains(usage, "[--flags]") || + cmd.HasFlags() || + cmd.HasPersistentFlags() || + cmd.HasAvailableFlags() + + hasCommands := strings.Contains(usage, "[command]") || cmd.HasAvailableSubCommands() + for _, marker := range []string{ + "[args]", + "[flags]", "[--flags]", + "[command]", + } { + usage = strings.ReplaceAll(usage, marker, "") + } + + var optionalArgs []string //nolint:prealloc + + for _, arg := range optionalArgsRe.FindAllString(usage, -1) { + usage = strings.ReplaceAll(usage, arg, "") + optionalArgs = append(optionalArgs, arg) + } + + usage = strings.TrimSpace(usage) + + var useLine []string + + if complete { + parts := strings.Fields(usage) + if len(parts) > 0 { + useLine = append(useLine, styles.Name.Render(parts[0])) + } + + if len(parts) > 1 { + useLine = append(useLine, styles.Command.Render(" "+strings.Join(parts[1:], " "))) + } + } else { + useLine = append(useLine, styles.Command.Render(usage)) + } + + if hasCommands { + useLine = append(useLine, styles.DimmedArgument.Render(" [command]")) + } + + if hasArgs { + useLine = append(useLine, styles.DimmedArgument.Render(" [args]")) + } + + for _, arg := range optionalArgs { + useLine = append(useLine, styles.DimmedArgument.Render(" "+arg)) + } + + if hasFlags { + useLine = append(useLine, styles.DimmedArgument.Render(" [--flags]")) + } + + return lipgloss.JoinHorizontal(lipgloss.Left, useLine...) +} + +// groups returns Cobra command group titles in their render order. +func (r helpRenderer) groups() (map[string]string, []string) { + ids := []string{""} + groups := map[string]string{"": "commands"} + + for _, group := range r.cmd.Groups() { + ids = append(ids, group.ID) + groups[group.ID] = group.Title + } + + return groups, ids +} + +// commandGroups collects available subcommands grouped by Cobra group ID. +func (r helpRenderer) commandGroups() map[string][]commandHelpItem { + commands := map[string][]commandHelpItem{} + + for _, subCmd := range r.cmd.Commands() { + if !subCmd.IsAvailableCommand() && subCmd.Name() != "help" { + continue + } + + commands[subCmd.GroupID] = append(commands[subCmd.GroupID], commandHelpItem{ + name: subCmd.Name(), + subgroup: subCmd.Annotations[annotationSubGroupName], + key: r.ctx.Styles.Program.Command.Render(subCmd.Name()), + help: renderHelpDescription(r.ctx.Styles, subCmd.Short), + }) + } + + for groupID := range commands { + slices.SortFunc(commands[groupID], func(a, b commandHelpItem) int { + return strings.Compare(a.name, b.name) + }) + } + + return commands +} + +// optionItems merges docopt options and Cobra flags into one rendered option list. +func (r helpRenderer) optionItems() ([]helpItem, string) { + items := make([]helpItem, 0) + docoptOptions := commandHelpOptions(r.cmd) + + for _, option := range docoptOptions { + items = append(items, helpItem{ + key: renderDocoptFlag(r.ctx.Styles.Program, option.Display), + help: renderHelpDescription(r.ctx.Styles, option.Description), + }) + } + + r.cmd.Flags().VisitAll(func(flag *pflag.Flag) { + if flag.Hidden || shouldSkipHelpFlag(r.cmd, flag) { + return + } + + help := renderHelpDescription(r.ctx.Styles, flag.Usage) + if flag.DefValue != "" && flag.DefValue != "false" && flag.DefValue != "0" && flag.DefValue != "[]" { + help += r.ctx.Styles.FlagDefault.Render(" (" + flag.DefValue + ")") + } + + items = append(items, helpItem{ + key: renderCobraFlag(r.ctx.Styles.Program, flag), + help: help, + }) + }) + + if len(docoptOptions) > 0 { + return items, "options" + } + + return items, "flags" +} + +// commandHelpOptions decodes docopt help options stored on the Cobra command. +func commandHelpOptions(cmd *cobra.Command) []docopt.HelpOption { + payload := cmd.Annotations[annotationHelpOptions] + if payload == "" { + return nil + } + + var options []docopt.HelpOption + if err := json.Unmarshal([]byte(payload), &options); err != nil { + return nil + } + + return options +} + +// shouldSkipHelpFlag hides inherited help flags on subcommands unless explicitly changed. +func shouldSkipHelpFlag(cmd *cobra.Command, flag *pflag.Flag) bool { + return flag.Name == "help" && cmd != cmd.Root() && !flag.Changed +} + +// renderCobraFlag renders one Cobra flag key with lets' program styles. +func renderCobraFlag(styles fang.Program, flag *pflag.Flag) string { + if flag.Shorthand == "" { + return styles.Flag.Render("--" + flag.Name) + } + + return styles.Flag.Render("-" + flag.Shorthand + " --" + flag.Name) +} + +// renderDocoptFlag renders a docopt option display string, including aliases. +func renderDocoptFlag(styles fang.Program, display string) string { + parts := strings.Split(display, ", ") + rendered := make([]string, 0, len(parts)) + + for _, part := range parts { + rendered = append(rendered, renderDocoptFlagPart(styles, part)) + } + + return strings.Join(rendered, styles.DimmedArgument.Render(", ")) +} + +// renderDocoptFlagPart styles one docopt option or argument fragment. +func renderDocoptFlagPart(styles fang.Program, part string) string { + if left, right, ok := strings.Cut(part, "="); ok { + return styles.Flag.Render(left+"=") + styles.Flag.Render(right) + } + + return styles.Flag.Render(part) +} + +// renderHelpDescription styles single and multi-line help descriptions. +func renderHelpDescription(styles fang.Styles, usage string) string { + if !strings.Contains(usage, "\n") { + return styles.FlagDescription.Render(usage) + } + + noTransform := styles.FlagDescription.UnsetTransform() + parts := strings.Split(usage, "\n") + lines := make([]string, 0, len(parts)) + + for i, line := range parts { + if line == "" { + lines = append(lines, "") + continue + } + + if i == 0 { + lines = append(lines, styles.FlagDescription.Render(line)) + continue + } + + lines = append(lines, noTransform.Render(line)) + } + + return strings.Join(lines, "\n") +} + +// renderHelpGroup prints a generic key/description help section. +// +// Example output: +// +// FLAGS +// --debug Enable debug logging +func (r helpRenderer) renderHelpGroup(space int, title string, items []helpItem) { + r.out.sectionTitle(title) + + for _, item := range items { + r.renderHelpItem(space, item.key, item.help) + } +} + +// renderCommandGroup prints one command section, including subgroup headings when useful. +// +// Example output: +// +// COMMANDS +// release Create a release +// +// CI +// lint Run lint checks +func (r helpRenderer) renderCommandGroup(space int, title string, items []commandHelpItem, hasSubgroups bool) { + r.out.sectionTitle(title) + + names := subgroupNames(items) + showSubgroupTitles := len(names) > 1 + + bySubgroup := make(map[string][]commandHelpItem, len(names)) + + var ungrouped []commandHelpItem + + for _, item := range items { + if item.subgroup == "" { + ungrouped = append(ungrouped, item) + } else { + bySubgroup[item.subgroup] = append(bySubgroup[item.subgroup], item) + } + } + + for _, subgroup := range names { + if showSubgroupTitles { + r.out.subgroupTitle(subgroup) + } + + for _, item := range bySubgroup[subgroup] { + r.renderHelpItem(space, displayCommandKey(item, hasSubgroups), item.help) + } + } + + for _, item := range ungrouped { + r.renderHelpItem(space, displayCommandKey(item, hasSubgroups), item.help) + } +} + +// renderHelpItem prints one aligned key/description row. +// +// Example output: +// +// --config Path to lets config +func (r helpRenderer) renderHelpItem(space int, key string, help string) { + r.out.writeln(lipgloss.JoinHorizontal( + lipgloss.Left, + paddedLeft2.Render(key), + strings.Repeat(" ", max(space-lipgloss.Width(key), 0)), + help, + )) +} + +// subgroupNames returns sorted unique subgroup names present in command items. +func subgroupNames(items []commandHelpItem) []string { + seen := map[string]struct{}{} + + var names []string + + for _, item := range items { + if item.subgroup == "" { + continue + } + + if _, ok := seen[item.subgroup]; ok { + continue + } + + seen[item.subgroup] = struct{}{} + names = append(names, item.subgroup) + } + + slices.Sort(names) + + return names +} + +// hasMultipleSubgroups reports whether command rendering needs subgroup-aware alignment. +func hasMultipleSubgroups(commands map[string][]commandHelpItem) bool { + seen := map[string]struct{}{} + + for _, items := range commands { + for _, item := range items { + if item.subgroup == "" { + continue + } + + seen[item.subgroup] = struct{}{} + if len(seen) > 1 { + return true + } + } + } + + return false +} + +// displayCommandKey adjusts command key spacing for mixed grouped and ungrouped commands. +func displayCommandKey(item commandHelpItem, hasSubgroups bool) string { + if !hasSubgroups { + return item.key + } + + if item.subgroup != "" { + return " " + item.key + } + + return item.key + " " +} + +// helpSpace calculates the aligned key column width for commands and options. +func (r helpRenderer) helpSpace(commands map[string][]commandHelpItem, flags []helpItem, hasSubgroups bool) int { + space := 10 + + for _, items := range commands { + for _, item := range items { + space = max(space, lipgloss.Width(displayCommandKey(item, hasSubgroups))+2) + } + } + + for _, item := range flags { + space = max(space, lipgloss.Width(item.key)+2) + } + + return space +} diff --git a/internal/cmd/help_golden_test.go b/internal/cmd/help_golden_test.go new file mode 100644 index 00000000..55769336 --- /dev/null +++ b/internal/cmd/help_golden_test.go @@ -0,0 +1,148 @@ +package cmd + +import ( + "bytes" + "context" + "fmt" + "path/filepath" + "testing" + + "github.com/charmbracelet/x/exp/golden" + "github.com/lets-cli/fang" + "github.com/lets-cli/lets/internal/config" + "github.com/lets-cli/lets/internal/executor" + "github.com/lets-cli/lets/internal/theme" + "github.com/spf13/cobra" +) + +func newGoldenRoot(t *testing.T) *cobra.Command { + t.Helper() + t.Setenv("__FANG_TEST_WIDTH", "80") + root := CreateRootCommand("0.0.0-dev", "") + root.InitDefaultHelpFlag() + root.InitDefaultVersionFlag() + InitSelfCmd(root, "0.0.0-dev") + InitCompletionCmd(root, nil) + root.InitDefaultHelpCmd() + return root +} + +func newGoldenRootFromYAML(t *testing.T, fixture string) *cobra.Command { + t.Helper() + root := newGoldenRoot(t) + absPath, err := filepath.Abs(filepath.Join("testdata", "fixtures", fixture)) + if err != nil { + t.Fatalf("fixture path: %v", err) + } + conf, err := config.Load(absPath, "", "0.0.0-dev") + if err != nil { + t.Fatalf("load config %s: %v", fixture, err) + } + InitSubCommands(root, conf, false, nil) + return root +} + +func goldenExecute(t *testing.T, root *cobra.Command, args []string) { + t.Helper() + var stdout, stderr bytes.Buffer + root.SetOut(&stdout) + root.SetErr(&stderr) + root.SetArgs(args) + err := fang.Execute( + context.Background(), root, + fang.WithVersion(root.Version), + fang.WithColorSchemeFunc(theme.DefaultColorScheme), + fang.WithErrorHandler(ErrorHandler), + fang.WithHelpRenderer(HelpRenderer), + ) + if err != nil { + golden.RequireEqual(t, stderr.Bytes()) + return + } + golden.RequireEqual(t, stdout.Bytes()) +} + +// TestHelpGolden covers root-level help rendering for various command configurations. +// To update golden files: go test ./internal/cmd/ -run TestHelpGolden -update +func TestHelpGolden(t *testing.T) { + t.Run("basic", func(t *testing.T) { + root := newGoldenRootFromYAML(t, "basic.yaml") + goldenExecute(t, root, []string{"--help"}) + }) + + t.Run("long_command", func(t *testing.T) { + root := newGoldenRootFromYAML(t, "long_command.yaml") + goldenExecute(t, root, []string{"--help"}) + }) + + t.Run("grouped_commands", func(t *testing.T) { + root := newGoldenRootFromYAML(t, "grouped_commands.yaml") + goldenExecute(t, root, []string{"--help"}) + }) + + t.Run("grouped_commands_long", func(t *testing.T) { + root := newGoldenRootFromYAML(t, "grouped_commands_long.yaml") + goldenExecute(t, root, []string{"--help"}) + }) +} + +// TestCommandHelpGolden covers per-subcommand help rendering with docopt options. +// To update golden files: go test ./internal/cmd/ -run TestCommandHelpGolden -update +func TestCommandHelpGolden(t *testing.T) { + t.Run("long_description", func(t *testing.T) { + root := newGoldenRootFromYAML(t, "command_help.yaml") + goldenExecute(t, root, []string{"help", "test"}) + }) + + t.Run("short_only", func(t *testing.T) { + root := newGoldenRootFromYAML(t, "command_help.yaml") + goldenExecute(t, root, []string{"help", "test2"}) + }) +} + +// TestErrorGolden covers error rendering: unknown commands and dependency trees. +// Error tests use synthetic commands to inject DependencyError directly, +// keeping them focused on the renderer rather than shell execution. +// To update golden files: go test ./internal/cmd/ -run TestErrorGolden -update +func TestErrorGolden(t *testing.T) { + t.Run("command_not_found", func(t *testing.T) { + root := newGoldenRootFromYAML(t, "basic.yaml") + goldenExecute(t, root, []string{"zzzznotacommand"}) + }) + + t.Run("command_not_found_with_suggestion", func(t *testing.T) { + // "self" is added by newGoldenRoot; "slef" triggers the suggestion + root := newGoldenRoot(t) + goldenExecute(t, root, []string{"slef"}) + }) + + t.Run("dependency_single", func(t *testing.T) { + root := newGoldenRoot(t) + root.AddCommand(&cobra.Command{ + Use: "lint", + GroupID: "main", + RunE: func(*cobra.Command, []string) error { + return &executor.DependencyError{ + Chain: []string{"lint"}, + Err: fmt.Errorf("exit status 1"), + } + }, + }) + goldenExecute(t, root, []string{"lint"}) + }) + + t.Run("dependency_chain", func(t *testing.T) { + root := newGoldenRoot(t) + root.AddCommand(&cobra.Command{ + Use: "deploy", + GroupID: "main", + RunE: func(*cobra.Command, []string) error { + return &executor.DependencyError{ + Chain: []string{"deploy", "build", "lint"}, + Err: fmt.Errorf("exit status 1"), + } + }, + }) + goldenExecute(t, root, []string{"deploy"}) + }) +} diff --git a/internal/cmd/help_test.go b/internal/cmd/help_test.go new file mode 100644 index 00000000..46fa5eae --- /dev/null +++ b/internal/cmd/help_test.go @@ -0,0 +1,238 @@ +package cmd + +import ( + "bytes" + "context" + "strings" + "testing" + + "charm.land/lipgloss/v2" + "github.com/lets-cli/fang" + configpkg "github.com/lets-cli/lets/internal/config/config" + "github.com/lets-cli/lets/internal/env" + "github.com/lets-cli/lets/internal/executor" + "github.com/spf13/cobra" +) + +func TestHelpRendererShowsSubgroups(t *testing.T) { + root := CreateRootCommand("v0.0.0-test", "") + root.InitDefaultHelpFlag() + root.SetArgs(nil) + root.SetOut(new(bytes.Buffer)) + root.SetErr(new(bytes.Buffer)) + + root.AddCommand(&cobra.Command{ + Use: "build", + Short: "build stuff", + GroupID: "main", + RunE: func(*cobra.Command, []string) error { + return nil + }, + Annotations: map[string]string{ + annotationSubGroupName: "Development", + }, + }) + root.AddCommand(&cobra.Command{ + Use: "deploy", + Short: "deploy stuff", + GroupID: "main", + RunE: func(*cobra.Command, []string) error { + return nil + }, + Annotations: map[string]string{ + annotationSubGroupName: "Operations", + }, + }) + + var stdout bytes.Buffer + root.SetOut(&stdout) + root.SetErr(new(bytes.Buffer)) + + if err := fang.Execute(context.Background(), root, fang.WithHelpRenderer(HelpRenderer)); err != nil { + t.Fatalf("unexpected error: %v", err) + } + + out := stdout.String() + if !strings.Contains(out, "DEVELOPMENT") { + t.Fatalf("expected Development subgroup in output: %q", out) + } + if !strings.Contains(out, "OPERATIONS") { + t.Fatalf("expected Operations subgroup in output: %q", out) + } +} + +func TestHelpRendererUsesDocoptFlags(t *testing.T) { + root := CreateRootCommand("v0.0.0-test", "") + root.InitDefaultHelpFlag() + root.SetArgs([]string{"help", "release"}) + + release := newSubcommand(&configpkg.Command{ + Name: "release", + GroupName: "Common", + Description: "Create tag and push", + Docopts: `Usage: lets release --message= + +Options: + Set version (e.g. 1.0.0) + --message=, -m Release message + +Example: + lets release 1.0.0 -m "Release 1.0.0"`, + }, nil, false, nil) + root.AddCommand(release) + + var stdout bytes.Buffer + root.SetOut(&stdout) + root.SetErr(new(bytes.Buffer)) + + if err := fang.Execute(context.Background(), root, fang.WithHelpRenderer(HelpRenderer)); err != nil { + t.Fatalf("unexpected error: %v", err) + } + + out := stdout.String() + if !strings.Contains(out, "lets release --message=") { + t.Fatalf("expected usage in output: %q", out) + } + if !strings.Contains(out, "OPTIONS") { + t.Fatalf("expected options title in output: %q", out) + } + argIdx := strings.Index(out, "") + flagIdx := strings.Index(out, "--message=, -m") + if argIdx == -1 { + t.Fatalf("expected docopt argument in output: %q", out) + } + if !strings.Contains(out, "--message=, -m") { + t.Fatalf("expected docopt flag in output: %q", out) + } + if argIdx > flagIdx { + t.Fatalf("expected docopt argument before flag in output: %q", out) + } + if strings.Contains(out, "-h --help") { + t.Fatalf("did not expect help flag in output: %q", out) + } +} + +func TestHelpRendererUsesCommandNameInRootCommandList(t *testing.T) { + root := CreateRootCommand("v0.0.0-test", "") + root.InitDefaultHelpFlag() + root.SetArgs(nil) + + release := newSubcommand(&configpkg.Command{ + Name: "release", + GroupName: "Common", + Description: "Create tag and push", + Docopts: `Usage: lets release --message= + +Options: + --message=, -m Release message`, + }, nil, false, nil) + root.AddCommand(release) + + var stdout bytes.Buffer + root.SetOut(&stdout) + root.SetErr(new(bytes.Buffer)) + + if err := fang.Execute(context.Background(), root, fang.WithHelpRenderer(HelpRenderer)); err != nil { + t.Fatalf("unexpected error: %v", err) + } + + out := stdout.String() + if !strings.Contains(out, "release") { + t.Fatalf("expected command name in output: %q", out) + } + if strings.Contains(out, "release --message=") { + t.Fatalf("did not expect command usage in root command list: %q", out) + } +} + +func TestHelpRendererDoesNotDuplicateDocoptUsageCommandPath(t *testing.T) { + root := CreateRootCommand("v0.0.0-test", "") + root.InitDefaultHelpFlag() + root.SetArgs([]string{"help", "build"}) + + build := newSubcommand(&configpkg.Command{ + Name: "build", + GroupName: "Common", + Description: "Build lets from source code", + Docopts: `Usage: + lets build + lets build []`, + }, nil, false, nil) + root.AddCommand(build) + + var stdout bytes.Buffer + root.SetOut(&stdout) + root.SetErr(new(bytes.Buffer)) + + if err := fang.Execute(context.Background(), root, fang.WithHelpRenderer(HelpRenderer)); err != nil { + t.Fatalf("unexpected error: %v", err) + } + + out := stdout.String() + if strings.Contains(out, "lets build lets build") { + t.Fatalf("did not expect duplicated command path in output: %q", out) + } + if !strings.Contains(out, "lets build []") { + t.Fatalf("expected docopt usage in output: %q", out) + } +} + +func TestErrorHandlerRemovesErrorHeaderLeftPadding(t *testing.T) { + var stderr bytes.Buffer + styles := fang.Styles{ + ErrorHeader: lipgloss.NewStyle().Padding(0, 1).SetString("ERROR"), + ErrorText: lipgloss.NewStyle(), + Program: fang.Program{ + Flag: lipgloss.NewStyle(), + }, + } + + ErrorHandler(&stderr, styles, &unknownCommandError{message: `unknown command "wat" for "lets"`}) + + out := stderr.String() + if !strings.Contains(out, "ERROR") { + t.Fatalf("expected error header in output: %q", out) + } + if !strings.Contains(out, "\nTry --help for usage.") { + t.Fatalf("expected usage hint in output: %q", out) + } +} + +func TestErrorHandlerSplitsExecuteErrorCause(t *testing.T) { + var stderr bytes.Buffer + styles := fang.Styles{ + ErrorHeader: lipgloss.NewStyle().SetString("ERROR"), + ErrorText: lipgloss.NewStyle(), + Program: fang.Program{ + Command: lipgloss.NewStyle(), + DimmedArgument: lipgloss.NewStyle(), + Flag: lipgloss.NewStyle(), + }, + Title: lipgloss.NewStyle(), + } + command := &configpkg.Command{ + Name: "build-lets-image", + Cmds: configpkg.Cmds{ + Commands: []*configpkg.Cmd{{Script: "exit 127"}}, + }, + } + conf := &configpkg.Config{Shell: "bash"} + env.SetDebugLevel(0) + err := executor.NewExecutor(conf, nil).Execute(executor.NewExecutorCtx(context.Background(), command)) + if err == nil { + t.Fatal("expected executor error") + } + + ErrorHandler(&stderr, styles, err) + + out := stderr.String() + if !strings.Contains(out, "failed to run command 'build-lets-image'.") { + t.Fatalf("expected split error message in output: %q", out) + } + if !strings.Contains(out, "Exit status 127.") { + t.Fatalf("expected split exit status in output: %q", out) + } + if strings.Contains(out, "failed to run command 'build-lets-image': exit status 127.") { + t.Fatalf("did not expect combined error message in output: %q", out) + } +} diff --git a/internal/cmd/root.go b/internal/cmd/root.go index 30d87c1f..c6a2f7c2 100644 --- a/internal/cmd/root.go +++ b/internal/cmd/root.go @@ -1,13 +1,9 @@ package cmd import ( - "cmp" "fmt" - "slices" - "sort" "strings" - "github.com/lets-cli/lets/internal/set" "github.com/spf13/cobra" ) @@ -77,22 +73,9 @@ func newRootCmd(version, buildDate string) *cobra.Command { FParseErrWhitelist: cobra.FParseErrWhitelist{UnknownFlags: true}, // handle errors manually SilenceErrors: true, - // print help message manyally - SilenceUsage: true, + SilenceUsage: true, } - cmd.SetHelpFunc(func(c *cobra.Command, _ []string) { - var err error - if c == c.Root() { - err = PrintRootHelpMessage(c) - } else { - err = PrintHelpMessage(c) - } - - if err != nil { - c.Println(err) - } - }) cmd.AddGroup(&cobra.Group{ID: "main", Title: "Commands:"}, &cobra.Group{ID: "internal", Title: "Internal commands:"}) cmd.SetHelpCommandGroupID("internal") @@ -126,156 +109,3 @@ func initRootFlags(rootCmd *cobra.Command) { rootCmd.Flags().StringP("config", "c", "", "config file (default is lets.yaml)") rootCmd.Flags().Bool("all", false, "show all commands (including the ones with _)") } - -func PrintHelpMessage(cmd *cobra.Command) error { - help := cmd.UsageString() - help = fmt.Sprintf("%s\n\n%s", cmd.Short, help) - help = strings.Replace(help, "lets [command] --help", "lets help [command]", 1) - _, err := fmt.Fprint(cmd.OutOrStdout(), help) - - return err -} - -func maxCommandNameLen(cmd *cobra.Command) int { - commands := cmd.Commands() - if len(commands) == 0 { - return 0 - } - - maxCmd := slices.MaxFunc(commands, func(a, b *cobra.Command) int { - return cmp.Compare(len(a.Name()), len(b.Name())) - }) - - return len(maxCmd.Name()) -} - -func rpad(s string, padding int) string { - formattedString := fmt.Sprintf("%%-%ds", padding) - return fmt.Sprintf(formattedString, s) -} - -func hasSubgroup(cmd *cobra.Command) bool { - subgroups := make(map[string]struct{}) - - for _, c := range cmd.Commands() { - if subgroup, ok := c.Annotations["SubGroupName"]; ok && subgroup != "" { - subgroups[subgroup] = struct{}{} - if len(subgroups) > 1 { - return true - } - } - } - - return false -} - -func writeGroupCommandHelpLine(builder *strings.Builder, prefix string, name string, padding int, suffix string, short string) { - builder.WriteString(" ") - builder.WriteString(prefix) - builder.WriteString(rpad(name, padding)) - builder.WriteString(suffix) - builder.WriteString(" ") - builder.WriteString(short) - builder.WriteByte('\n') -} - -func buildGroupCommandHelp(cmd *cobra.Command, group *cobra.Group) string { - cmds := []*cobra.Command{} - - // select commands that belong to the specified group - for _, c := range cmd.Commands() { - if c.GroupID == group.ID && (c.IsAvailableCommand() || c.Name() == "help") { - cmds = append(cmds, c) - } - } - - padding := maxCommandNameLen(cmd) - - sort.Slice(cmds, func(i, j int) bool { - return cmds[i].Name() < cmds[j].Name() - }) - - // Create a list of subgroups - subGroupNameSet := set.NewSet[string]() - - for _, c := range cmds { - if subgroup, ok := c.Annotations["SubGroupName"]; ok && subgroup != "" { - subGroupNameSet.Add(subgroup) - } - } - - subGroupNameList := subGroupNameSet.ToList() - sort.Strings(subGroupNameList) - - // generate output - var builder strings.Builder - builder.WriteString(group.Title) - builder.WriteByte('\n') - - intend := "" - if hasSubgroup(cmd) { - intend = " " - } - - for _, subgroupName := range subGroupNameList { - if len(subGroupNameList) > 1 { - builder.WriteString("\n ") - builder.WriteString(subgroupName) - builder.WriteByte('\n') - } - - for _, c := range cmds { - if subgroup, ok := c.Annotations["SubGroupName"]; ok && subgroup == subgroupName { - writeGroupCommandHelpLine(&builder, intend, c.Name(), padding, "", c.Short) - } - } - } - - for _, c := range cmds { - if _, ok := c.Annotations["SubGroupName"]; !ok { - writeGroupCommandHelpLine(&builder, "", c.Name(), padding, intend, c.Short) - } - } - - builder.WriteByte('\n') - - return builder.String() -} - -func PrintRootHelpMessage(cmd *cobra.Command) error { - var builder strings.Builder - builder.WriteString(cmd.Short) - builder.WriteString("\n\n") - - // General - builder.WriteString("Usage:\n") - - if cmd.Runnable() { - fmt.Fprintf(&builder, " %s\n", cmd.UseLine()) - } - - if cmd.HasAvailableSubCommands() { - fmt.Fprintf(&builder, " %s [command]\n", cmd.CommandPath()) - } - - builder.WriteByte('\n') - - // Commands - for _, group := range cmd.Groups() { - builder.WriteString(buildGroupCommandHelp(cmd, group)) - } - - // Flags - if cmd.HasAvailableLocalFlags() { - builder.WriteString("Flags:\n") - builder.WriteString(cmd.LocalFlags().FlagUsagesWrapped(120)) - builder.WriteByte('\n') - } - - // Usage - fmt.Fprintf(&builder, `Use "%s help [command]" for more information about a command.`, cmd.CommandPath()) - - _, err := fmt.Fprint(cmd.OutOrStdout(), builder.String()) - - return err -} diff --git a/internal/cmd/subcommand.go b/internal/cmd/subcommand.go index ddc5615b..b61d44f6 100644 --- a/internal/cmd/subcommand.go +++ b/internal/cmd/subcommand.go @@ -1,6 +1,7 @@ package cmd import ( + "encoding/json" "errors" "fmt" "io" @@ -9,12 +10,16 @@ import ( "strings" "github.com/lets-cli/lets/internal/config/config" + "github.com/lets-cli/lets/internal/docopt" "github.com/lets-cli/lets/internal/executor" "github.com/spf13/cobra" ) const ( - shortLimit = 120 + shortLimit = 120 + annotationSubGroupName = "SubGroupName" + annotationHelpOptions = "lets.helpOptions" + annotationHelpUsage = "lets.helpUsage" ) // cut all elements after command name. @@ -103,11 +108,17 @@ func filterCmds( return filteredCmds } +func replaceDocoptNamePlaceholder(docopts string, cmdName string) string { + docopts = strings.Replace(docopts, "${LETS_COMMAND_NAME}", cmdName, 1) + docopts = strings.Replace(docopts, "$LETS_COMMAND_NAME", cmdName, 1) + + return docopts +} + // Replace command name placeholder if present // E.g. if command name is foo, lets ${LETS_COMMAND_NAME} will be lets foo. func setDocoptNamePlaceholder(c *config.Command) { - c.Docopts = strings.Replace(c.Docopts, "${LETS_COMMAND_NAME}", c.Name, 1) - c.Docopts = strings.Replace(c.Docopts, "$LETS_COMMAND_NAME", c.Name, 1) + c.Docopts = replaceDocoptNamePlaceholder(c.Docopts, c.Name) } type cmdFlags struct { @@ -154,14 +165,78 @@ func isHidden(cmdName string, showAll bool) bool { return false } +func hasHelpArg(args []string) bool { + for _, arg := range args { + if arg == "--help" || arg == "-h" { + return true + } + } + + return false +} + +func buildCommandUse(commandName string, usage string) string { + lines := make([]string, 0) + + for line := range strings.SplitSeq(usage, "\n") { + line = strings.TrimSpace(line) + if line == "" { + continue + } + + line = strings.TrimPrefix(line, "lets ") + lines = append(lines, line) + } + + if len(lines) == 0 { + return commandName + } + + return strings.Join(lines, "\n") +} + +func buildCommandAnnotations(command *config.Command, docopts string) map[string]string { + annotations := map[string]string{ + annotationSubGroupName: command.GroupName, + } + + docoptParts := docopt.ParseDocoptParts(docopts) + if usage := buildCommandUse(command.Name, docoptParts.Usage); usage != "" { + annotations[annotationHelpUsage] = usage + } + + helpOptions := docopt.ParseHelpOptions(docopts, command.Name) + if len(helpOptions) == 0 { + return annotations + } + + payload, err := json.Marshal(helpOptions) + if err != nil { + return annotations + } + + annotations[annotationHelpOptions] = string(payload) + + return annotations +} + // newSubcommand creates new cobra root subcommand from config.Command. func newSubcommand(command *config.Command, conf *config.Config, showAll bool, out io.Writer) *cobra.Command { + docopts := replaceDocoptNamePlaceholder(command.Docopts, command.Name) + docoptParts := docopt.ParseDocoptParts(docopts) + subCmd := &cobra.Command{ Use: command.Name, + Example: docoptParts.Example, Short: short(command.Description), + Long: command.Description, GroupID: "main", Hidden: isHidden(command.Name, showAll), RunE: func(cmd *cobra.Command, args []string) error { + if hasHelpArg(args) { + return cmd.Help() + } + command.Args = append(command.Args, prepareArgs(command.Name, os.Args)...) command.Cmds.AppendArgs(args) @@ -198,14 +273,7 @@ func newSubcommand(command *config.Command, conf *config.Config, showAll bool, o SilenceUsage: true, } - subCmd.SetHelpFunc(func(c *cobra.Command, strings []string) { - if _, err := fmt.Fprint(c.OutOrStdout(), command.Help()); err != nil { - c.Println(err) - } - }) - subCmd.Annotations = map[string]string{ - "SubGroupName": command.GroupName, - } + subCmd.Annotations = buildCommandAnnotations(command, docopts) return subCmd } diff --git a/internal/cmd/subcommand_test.go b/internal/cmd/subcommand_test.go new file mode 100644 index 00000000..08100939 --- /dev/null +++ b/internal/cmd/subcommand_test.go @@ -0,0 +1,113 @@ +package cmd + +import ( + "encoding/json" + "testing" + + configpkg "github.com/lets-cli/lets/internal/config/config" + "github.com/lets-cli/lets/internal/docopt" + "github.com/spf13/cobra" +) + +func TestNewSubcommandUsesDocoptHelpMetadata(t *testing.T) { + command := &configpkg.Command{ + Name: "release", + GroupName: "Common", + Description: "Create tag and push", + Docopts: `Usage: lets release --message= + +Options: + Set version (e.g. 1.0.0) + --message=, -m Release message + +Example: + lets release 1.0.0 -m "Release 1.0.0"`, + } + + subCmd := newSubcommand(command, nil, false, nil) + if subCmd.Use != "release" { + t.Fatalf("unexpected use: %q", subCmd.Use) + } + if subCmd.Name() != "release" { + t.Fatalf("unexpected name: %q", subCmd.Name()) + } + if subCmd.Annotations[annotationHelpUsage] != "release --message=" { + t.Fatalf("unexpected help usage: %q", subCmd.Annotations[annotationHelpUsage]) + } + + if subCmd.Example != " lets release 1.0.0 -m \"Release 1.0.0\"" { + t.Fatalf("unexpected example: %q", subCmd.Example) + } + + payload := subCmd.Annotations[annotationHelpOptions] + if payload == "" { + t.Fatal("expected custom flags annotation") + } + + var helpFlags []docopt.HelpOption + if err := json.Unmarshal([]byte(payload), &helpFlags); err != nil { + t.Fatalf("unexpected unmarshal error: %v", err) + } + + if len(helpFlags) != 2 { + t.Fatalf("expected 2 help flags, got %d", len(helpFlags)) + } + + if helpFlags[0].Display != "" { + t.Fatalf("unexpected first display: %q", helpFlags[0].Display) + } + if helpFlags[1].Display != "--message=, -m" { + t.Fatalf("unexpected second display: %q", helpFlags[1].Display) + } +} + +func TestNewSubcommandKeepsConfiguredNameWhenDocoptUsageDiffers(t *testing.T) { + command := &configpkg.Command{ + Name: "options-wrong-usage", + GroupName: "Common", + Docopts: `Usage: lets options-wrong-usage-xxx + +Options: + --message=, -m Release message`, + } + + root := CreateRootCommand("v0.0.0-test", "") + subCmd := newSubcommand(command, nil, false, nil) + root.AddCommand(subCmd) + + found, _, err := root.Find([]string{"options-wrong-usage"}) + if err != nil { + t.Fatalf("unexpected find error: %v", err) + } + if found.Name() != "options-wrong-usage" { + t.Fatalf("unexpected command name: %q", found.Name()) + } + if found.Annotations[annotationHelpUsage] != "options-wrong-usage-xxx" { + t.Fatalf("unexpected help usage: %q", found.Annotations[annotationHelpUsage]) + } +} + +func TestSubcommandHelpArg(t *testing.T) { + command := &configpkg.Command{ + Name: "release", + GroupName: "Common", + Description: "Create tag and push", + } + + root := CreateRootCommand("v0.0.0-test", "") + subCmd := newSubcommand(command, nil, false, nil) + root.AddCommand(subCmd) + root.SetArgs([]string{"release", "--help"}) + + called := false + subCmd.SetHelpFunc(func(*cobra.Command, []string) { + called = true + }) + + if err := root.Execute(); err != nil { + t.Fatalf("unexpected error: %v", err) + } + if !called { + t.Fatal("expected subcommand help to be called") + } +} diff --git a/internal/cmd/testdata/TestCommandHelpGolden/long_description.golden b/internal/cmd/testdata/TestCommandHelpGolden/long_description.golden new file mode 100644 index 00000000..be5dfa21 --- /dev/null +++ b/internal/cmd/testdata/TestCommandHelpGolden/long_description.golden @@ -0,0 +1,10 @@ + +Run tests +Unit tests are essention for success. + +Example: lets test + +USAGE + + lets test [] [--flags] + diff --git a/internal/cmd/testdata/TestCommandHelpGolden/short_only.golden b/internal/cmd/testdata/TestCommandHelpGolden/short_only.golden new file mode 100644 index 00000000..e2044768 --- /dev/null +++ b/internal/cmd/testdata/TestCommandHelpGolden/short_only.golden @@ -0,0 +1,7 @@ + +Run tests + +USAGE + + lets test2 [] [--flags] + diff --git a/internal/cmd/testdata/TestErrorGolden/command_not_found.golden b/internal/cmd/testdata/TestErrorGolden/command_not_found.golden new file mode 100644 index 00000000..537faec8 --- /dev/null +++ b/internal/cmd/testdata/TestErrorGolden/command_not_found.golden @@ -0,0 +1,7 @@ + + ERROR + + Unknown command "zzzznotacommand" for "lets". + + Try --help for usage. + diff --git a/internal/cmd/testdata/TestErrorGolden/command_not_found_with_suggestion.golden b/internal/cmd/testdata/TestErrorGolden/command_not_found_with_suggestion.golden new file mode 100644 index 00000000..9fb08427 --- /dev/null +++ b/internal/cmd/testdata/TestErrorGolden/command_not_found_with_suggestion.golden @@ -0,0 +1,11 @@ + + ERROR + + Unknown command "slef" for "lets" + + Did you mean this? + self + . + + Try --help for usage. + diff --git a/internal/cmd/testdata/TestErrorGolden/dependency_chain.golden b/internal/cmd/testdata/TestErrorGolden/dependency_chain.golden new file mode 100644 index 00000000..0d77b6f4 --- /dev/null +++ b/internal/cmd/testdata/TestErrorGolden/dependency_chain.golden @@ -0,0 +1,10 @@ + + ERROR + + Exit status 1. + + COMMAND TREE: + └─ deploy + └─ build + └─ lint <-- failed here + diff --git a/internal/cmd/testdata/TestErrorGolden/dependency_single.golden b/internal/cmd/testdata/TestErrorGolden/dependency_single.golden new file mode 100644 index 00000000..985bc6c7 --- /dev/null +++ b/internal/cmd/testdata/TestErrorGolden/dependency_single.golden @@ -0,0 +1,8 @@ + + ERROR + + Exit status 1. + + COMMAND TREE: + └─ lint <-- failed here + diff --git a/internal/cmd/testdata/TestHelpGolden/basic.golden b/internal/cmd/testdata/TestHelpGolden/basic.golden new file mode 100644 index 00000000..c818c5e7 --- /dev/null +++ b/internal/cmd/testdata/TestHelpGolden/basic.golden @@ -0,0 +1,27 @@ + +A CLI task runner + +USAGE + + lets [command] [--flags] + +COMMANDS: + bar Print bar + foo Print foo + +INTERNAL COMMANDS: + help Help about any command + self Manage lets CLI itself + +FLAGS + --all Show all commands (including the ones with _) + -c --config Config file (default is lets.yaml) + -d --debug Show debug logs (or use LETS_DEBUG=1). If used multiple times, shows more verbose logs + -E --env Set env variable for running command KEY=VALUE + --exclude Run all but excluded command(s) described in cmd as map + -h --help Help for lets + --init Create a new lets.yaml in the current folder + --no-depends Skip 'depends' for running command + --only Run only specified command(s) described in cmd as map + -v --version Version for lets + diff --git a/internal/cmd/testdata/TestHelpGolden/grouped_commands.golden b/internal/cmd/testdata/TestHelpGolden/grouped_commands.golden new file mode 100644 index 00000000..4c58fd6e --- /dev/null +++ b/internal/cmd/testdata/TestHelpGolden/grouped_commands.golden @@ -0,0 +1,32 @@ + +A CLI task runner + +USAGE + + lets [command] [--flags] + +COMMANDS: + A GROUP + c C command + B GROUP + a A command + b B command + COMMON + d D command + +INTERNAL COMMANDS: + help Help about any command + self Manage lets CLI itself + +FLAGS + --all Show all commands (including the ones with _) + -c --config Config file (default is lets.yaml) + -d --debug Show debug logs (or use LETS_DEBUG=1). If used multiple times, shows more verbose logs + -E --env Set env variable for running command KEY=VALUE + --exclude Run all but excluded command(s) described in cmd as map + -h --help Help for lets + --init Create a new lets.yaml in the current folder + --no-depends Skip 'depends' for running command + --only Run only specified command(s) described in cmd as map + -v --version Version for lets + diff --git a/internal/cmd/testdata/TestHelpGolden/grouped_commands_long.golden b/internal/cmd/testdata/TestHelpGolden/grouped_commands_long.golden new file mode 100644 index 00000000..ff0e0174 --- /dev/null +++ b/internal/cmd/testdata/TestHelpGolden/grouped_commands_long.golden @@ -0,0 +1,33 @@ + +A CLI task runner + +USAGE + + lets [command] [--flags] + +COMMANDS: + A GROUP + c C command + B GROUP + a A command + b B command + COMMON + d D command + super_long_command_longer_than_usual Super long command + +INTERNAL COMMANDS: + help Help about any command + self Manage lets CLI itself + +FLAGS + --all Show all commands (including the ones with _) + -c --config Config file (default is lets.yaml) + -d --debug Show debug logs (or use LETS_DEBUG=1). If used multiple times, shows more verbose logs + -E --env Set env variable for running command KEY=VALUE + --exclude Run all but excluded command(s) described in cmd as map + -h --help Help for lets + --init Create a new lets.yaml in the current folder + --no-depends Skip 'depends' for running command + --only Run only specified command(s) described in cmd as map + -v --version Version for lets + diff --git a/internal/cmd/testdata/TestHelpGolden/long_command.golden b/internal/cmd/testdata/TestHelpGolden/long_command.golden new file mode 100644 index 00000000..f21c0521 --- /dev/null +++ b/internal/cmd/testdata/TestHelpGolden/long_command.golden @@ -0,0 +1,28 @@ + +A CLI task runner + +USAGE + + lets [command] [--flags] + +COMMANDS: + bar Print bar + foo Print foo + super_long_command_longer_than_usual Super long command + +INTERNAL COMMANDS: + help Help about any command + self Manage lets CLI itself + +FLAGS + --all Show all commands (including the ones with _) + -c --config Config file (default is lets.yaml) + -d --debug Show debug logs (or use LETS_DEBUG=1). If used multiple times, shows more verbose logs + -E --env Set env variable for running command KEY=VALUE + --exclude Run all but excluded command(s) described in cmd as map + -h --help Help for lets + --init Create a new lets.yaml in the current folder + --no-depends Skip 'depends' for running command + --only Run only specified command(s) described in cmd as map + -v --version Version for lets + diff --git a/internal/cmd/testdata/fixtures/basic.yaml b/internal/cmd/testdata/fixtures/basic.yaml new file mode 100644 index 00000000..4e026704 --- /dev/null +++ b/internal/cmd/testdata/fixtures/basic.yaml @@ -0,0 +1,14 @@ +shell: bash + +commands: + _x: + description: Hidden x + cmd: echo "x" + + foo: + description: Print foo + cmd: echo "Foo" + + bar: + description: Print bar + cmd: echo "Bar" \ No newline at end of file diff --git a/internal/cmd/testdata/fixtures/command_help.yaml b/internal/cmd/testdata/fixtures/command_help.yaml new file mode 100644 index 00000000..fb08d4af --- /dev/null +++ b/internal/cmd/testdata/fixtures/command_help.yaml @@ -0,0 +1,18 @@ +shell: bash + +commands: + test: + description: | + Run tests + Unit tests are essention for success. + + Example: lets test + options: | + Usage: lets test [] + cmd: echo "Tests" + + test2: + description: Run tests + options: | + Usage: lets test2 [] + cmd: echo "Tests" diff --git a/internal/cmd/testdata/fixtures/grouped_commands.yaml b/internal/cmd/testdata/fixtures/grouped_commands.yaml new file mode 100644 index 00000000..9ea0640f --- /dev/null +++ b/internal/cmd/testdata/fixtures/grouped_commands.yaml @@ -0,0 +1,21 @@ +shell: bash + +commands: + b: + group: B group + description: b command + cmd: echo + + a: + group: B group + description: a command + cmd: echo + + c: + group: A group + description: c command + cmd: echo + + d: + description: d command + cmd: echo diff --git a/internal/cmd/testdata/fixtures/grouped_commands_long.yaml b/internal/cmd/testdata/fixtures/grouped_commands_long.yaml new file mode 100644 index 00000000..1a85cb8c --- /dev/null +++ b/internal/cmd/testdata/fixtures/grouped_commands_long.yaml @@ -0,0 +1,25 @@ +shell: bash + +commands: + b: + group: B group + description: b command + cmd: echo + + a: + group: B group + description: a command + cmd: echo + + c: + group: A group + description: c command + cmd: echo + + d: + description: d command + cmd: echo + + super_long_command_longer_than_usual: + description: Super long command + cmd: echo "long command" diff --git a/internal/cmd/testdata/fixtures/long_command.yaml b/internal/cmd/testdata/fixtures/long_command.yaml new file mode 100644 index 00000000..a3462d59 --- /dev/null +++ b/internal/cmd/testdata/fixtures/long_command.yaml @@ -0,0 +1,18 @@ +shell: bash + +commands: + _x: + description: Hidden x + cmd: echo "x" + + foo: + description: Print foo + cmd: echo "Foo" + + bar: + description: Print bar + cmd: echo "Bar" + + super_long_command_longer_than_usual: + description: Super long command + cmd: echo "long command" diff --git a/internal/docopt/docopts.go b/internal/docopt/docopts.go index 88610c61..ff57a018 100644 --- a/internal/docopt/docopts.go +++ b/internal/docopt/docopts.go @@ -2,6 +2,7 @@ package docopt import ( "fmt" + "regexp" "strconv" "strings" @@ -148,3 +149,142 @@ func normalizeKey(origKey string) string { return key } + +type docoptParts struct { + Usage string + Options string + Example string +} + +type HelpOption struct { + Display string `json:"display"` + Description string `json:"description"` + Name string `json:"name,omitempty"` + Short string `json:"short,omitempty"` + Long string `json:"long,omitempty"` + Kind string `json:"kind,omitempty"` +} + +var helpOptionSeparator = regexp.MustCompile(`\s{2,}`) + +func ParseDocoptParts(docopt string) docoptParts { + sections := map[string]*strings.Builder{ + "usage": {}, + "options": {}, + "example": {}, + } + + section := "" + + for line := range strings.SplitSeq(docopt, "\n") { + switch { + case strings.HasPrefix(line, "Usage:"): + section = "usage" + line = strings.TrimSpace(strings.TrimPrefix(line, "Usage:")) + case strings.HasPrefix(line, "Options:"): + section = "options" + line = strings.TrimSpace(strings.TrimPrefix(line, "Options:")) + case strings.HasPrefix(line, "Example:"): + section = "example" + line = strings.TrimSpace(strings.TrimPrefix(line, "Example:")) + } + + if section == "" || line == "" { + continue + } + + text := sections[section] + if text.Len() > 0 { + text.WriteByte('\n') + } + + text.WriteString(line) + } + + return docoptParts{ + Usage: sections["usage"].String(), + Options: sections["options"].String(), + Example: sections["example"].String(), + } +} + +func ParseHelpOptions(docopt string, cmdName string) []HelpOption { + parts := ParseDocoptParts(docopt) + if parts.Options == "" { + return nil + } + + rawOpts, err := ParseOptions(docopt, cmdName) + if err != nil { + rawOpts = nil + } + + var options []HelpOption + + for line := range strings.SplitSeq(parts.Options, "\n") { + trimmed := strings.TrimLeft(line, " \t") + if trimmed == "" { + continue + } + + parts := helpOptionSeparator.Split(trimmed, 2) + if len(parts) == 0 { + continue + } + + display := strings.TrimSpace(parts[0]) + if display == "" { + continue + } + + if !strings.HasPrefix(display, "-") && !strings.HasPrefix(display, "<") { + if len(options) == 0 { + continue + } + + description := strings.TrimSpace(trimmed) + if description == "" { + continue + } + + if options[len(options)-1].Description != "" { + options[len(options)-1].Description += "\n" + } + + options[len(options)-1].Description += description + + continue + } + + option := HelpOption{Display: display} + if len(parts) > 1 { + option.Description = strings.TrimSpace(parts[1]) + } + + if strings.HasPrefix(display, "<") { + option.Kind = "arg" + } else { + option.Kind = "flag" + } + + for _, rawOpt := range rawOpts { + if rawOpt.Name == cmdName { + continue + } + + if !strings.Contains(display, rawOpt.Name) && (rawOpt.Short == "" || !strings.Contains(display, rawOpt.Short)) { + continue + } + + option.Name = rawOpt.Name + option.Short = rawOpt.Short + option.Long = rawOpt.Long + + break + } + + options = append(options, option) + } + + return options +} diff --git a/internal/docopt/docopts_test.go b/internal/docopt/docopts_test.go new file mode 100644 index 00000000..a0dbab8c --- /dev/null +++ b/internal/docopt/docopts_test.go @@ -0,0 +1,70 @@ +package docopt + +import "testing" + +const releaseDocopt = `Usage: lets release --message= + +Options: + Set version (e.g. 1.0.0) + --message=, -m Release message + +Example: + lets release 1.0.0 -m "Release 1.0.0" + lets release 1.0.0-rc1 -m "Prerelease 1.0.0-rc1"` + +func TestParseDocoptParts(t *testing.T) { + parts := ParseDocoptParts(releaseDocopt) + + if parts.Usage != "lets release --message=" { + t.Fatalf("unexpected usage: %q", parts.Usage) + } + + expectedOptions := " Set version (e.g. 1.0.0)\n --message=, -m Release message" + if parts.Options != expectedOptions { + t.Fatalf("unexpected options: %q", parts.Options) + } + + expectedExample := " lets release 1.0.0 -m \"Release 1.0.0\"\n lets release 1.0.0-rc1 -m \"Prerelease 1.0.0-rc1\"" + if parts.Example != expectedExample { + t.Fatalf("unexpected example: %q", parts.Example) + } +} + +func TestParseHelpOptions(t *testing.T) { + helpOptions := ParseHelpOptions(releaseDocopt, "release") + if len(helpOptions) != 2 { + t.Fatalf("expected 2 help options, got %d", len(helpOptions)) + } + + if helpOptions[0].Display != "" { + t.Fatalf("unexpected first display: %q", helpOptions[0].Display) + } + if helpOptions[0].Description != "Set version (e.g. 1.0.0)" { + t.Fatalf("unexpected first description: %q", helpOptions[0].Description) + } + if helpOptions[0].Kind != "arg" { + t.Fatalf("unexpected first kind: %q", helpOptions[0].Kind) + } + if helpOptions[0].Name != "" { + t.Fatalf("unexpected first name: %q", helpOptions[0].Name) + } + + if helpOptions[1].Display != "--message=, -m" { + t.Fatalf("unexpected second display: %q", helpOptions[1].Display) + } + if helpOptions[1].Description != "Release message" { + t.Fatalf("unexpected second description: %q", helpOptions[1].Description) + } + if helpOptions[1].Kind != "flag" { + t.Fatalf("unexpected second kind: %q", helpOptions[1].Kind) + } + if helpOptions[1].Name != "--message" { + t.Fatalf("unexpected second name: %q", helpOptions[1].Name) + } + if helpOptions[1].Short != "-m" { + t.Fatalf("unexpected second short: %q", helpOptions[1].Short) + } + if helpOptions[1].Long != "--message" { + t.Fatalf("unexpected second long: %q", helpOptions[1].Long) + } +} diff --git a/internal/executor/dependency_error.go b/internal/executor/dependency_error.go index b419d29d..7062669a 100644 --- a/internal/executor/dependency_error.go +++ b/internal/executor/dependency_error.go @@ -1,17 +1,6 @@ package executor -import ( - "errors" - "fmt" - "io" - "strings" - - "github.com/fatih/color" -) - -const dependencyTreeIndent = " " -const dependencyTreeHeader = "command failed:" -const dependencyTreeJoint = "└─ " +import "errors" // DependencyError carries the full dependency chain when a command fails. // Chain is outermost-first (e.g., ["deploy", "build", "lint"]). @@ -32,36 +21,6 @@ func (e *DependencyError) ExitCode() int { return 1 } -func (e *DependencyError) FailureMessage() string { - if executeErr, ok := errors.AsType[*ExecuteError](e.Err); ok { - return executeErr.Cause().Error() - } - - return e.Err.Error() -} - -func (e *DependencyError) TreeMessage() string { - red := color.New(color.FgRed).SprintFunc() - - var builder strings.Builder - - builder.WriteString(dependencyTreeHeader) - - for i, name := range e.Chain { - builder.WriteByte('\n') - builder.WriteString(strings.Repeat(dependencyTreeIndent, i+1)) - builder.WriteString(dependencyTreeJoint) - builder.WriteString(name) - - if i == len(e.Chain)-1 { - builder.WriteString(dependencyTreeIndent) - builder.WriteString(red("<-- failed here")) - } - } - - return builder.String() -} - // prependToChain prepends name to the chain in err if err is already a *DependencyError, // otherwise wraps err in a new single-element DependencyError. func prependToChain(name string, err error) error { @@ -71,10 +30,3 @@ func prependToChain(name string, err error) error { return &DependencyError{Chain: []string{name}, Err: err} } - -// PrintDependencyTree writes an indented tree of the dependency chain to w. -// The failing node (last in chain) is annotated in red. -// Respects NO_COLOR automatically via fatih/color. -func PrintDependencyTree(e *DependencyError, w io.Writer) { - fmt.Fprintln(w, e.TreeMessage()) -} diff --git a/internal/executor/dependency_error_test.go b/internal/executor/dependency_error_test.go index 2e20195e..c85665cd 100644 --- a/internal/executor/dependency_error_test.go +++ b/internal/executor/dependency_error_test.go @@ -1,12 +1,8 @@ package executor import ( - "bytes" "fmt" - "os" "os/exec" - "strings" - "syscall" "testing" ) @@ -102,93 +98,3 @@ func TestDependencyErrorError(t *testing.T) { t.Errorf("expected Error() to delegate to Err, got %q", depErr.Error()) } } - -func TestDependencyErrorFailureMessage(t *testing.T) { - t.Run("returns root cause for execute errors", func(t *testing.T) { - depErr := &DependencyError{ - Chain: []string{"lint"}, - Err: &ExecuteError{err: fmt.Errorf("failed to run command 'lint': %w", fmt.Errorf("exit status 1"))}, - } - - if got := depErr.FailureMessage(); got != "exit status 1" { - t.Fatalf("expected root cause message, got %q", got) - } - }) - - t.Run("keeps non execute errors intact", func(t *testing.T) { - depErr := &DependencyError{ - Chain: []string{"lint"}, - Err: fmt.Errorf("failed to calculate checksum for command 'lint': missing file"), - } - - if got := depErr.FailureMessage(); got != "failed to calculate checksum for command 'lint': missing file" { - t.Fatalf("expected original message, got %q", got) - } - }) - - t.Run("keeps path context for execute errors", func(t *testing.T) { - depErr := &DependencyError{ - Chain: []string{"lint"}, - Err: &ExecuteError{ - err: fmt.Errorf( - "failed to run command 'lint': %w", - &os.PathError{Op: "chdir", Path: "/tmp/missing", Err: syscall.ENOENT}, - ), - }, - } - - if got := depErr.FailureMessage(); got != "chdir /tmp/missing: no such file or directory" { - t.Fatalf("expected path-aware message, got %q", got) - } - }) -} - -func TestPrintDependencyTree(t *testing.T) { - t.Run("single node", func(t *testing.T) { - depErr := &DependencyError{Chain: []string{"lint"}, Err: fmt.Errorf("fail")} - var buf bytes.Buffer - PrintDependencyTree(depErr, &buf) - out := buf.String() - lines := strings.Split(strings.TrimRight(out, "\n"), "\n") - if len(lines) != 2 { - t.Fatalf("expected 2 lines, got %d: %v", len(lines), lines) - } - want := []string{ - dependencyTreeHeader, - dependencyTreeIndent + dependencyTreeJoint + "lint" + dependencyTreeIndent + "<-- failed here", - } - - for i := range want { - if lines[i] != want[i] { - t.Errorf("line %d: want %q, got %q", i, want[i], lines[i]) - } - } - }) - - t.Run("three nodes with correct indentation", func(t *testing.T) { - depErr := &DependencyError{ - Chain: []string{"deploy", "build", "lint"}, - Err: fmt.Errorf("fail"), - } - var buf bytes.Buffer - PrintDependencyTree(depErr, &buf) - lines := strings.Split(strings.TrimRight(buf.String(), "\n"), "\n") - - if len(lines) != 4 { - t.Fatalf("expected 4 lines, got %d: %v", len(lines), lines) - } - want := []string{ - dependencyTreeHeader, - dependencyTreeIndent + dependencyTreeJoint + "deploy", - strings.Repeat(dependencyTreeIndent, 2) + dependencyTreeJoint + "build", - strings.Repeat(dependencyTreeIndent, 3) + dependencyTreeJoint + "lint" + - dependencyTreeIndent + "<-- failed here", - } - - for i := range want { - if lines[i] != want[i] { - t.Errorf("line %d: want %q, got %q", i, want[i], lines[i]) - } - } - }) -} diff --git a/internal/theme/theme.go b/internal/theme/theme.go new file mode 100644 index 00000000..c0d2a622 --- /dev/null +++ b/internal/theme/theme.go @@ -0,0 +1,77 @@ +package theme + +import ( + "image/color" + + "charm.land/lipgloss/v2" + "github.com/charmbracelet/x/exp/charmtone" + "github.com/lets-cli/fang" +) + +// DefaultColorScheme is the default colorscheme. +func DefaultColorScheme(c lipgloss.LightDarkFunc) fang.ColorScheme { + baseCyan := charmtone.Turtle + baseWhite := charmtone.Ash + baseGray := charmtone.Oyster + + return fang.ColorScheme{ + Base: c(charmtone.Charcoal, baseCyan), + Title: baseWhite, + Codeblock: c(charmtone.Salt, lipgloss.Color("#2F2E36")), + Program: c(charmtone.Malibu, baseWhite), + Command: c(charmtone.Pony, baseCyan), + DimmedArgument: c(charmtone.Squid, baseGray), + Comment: c(charmtone.Squid, lipgloss.Color("#747282")), + Flag: c(lipgloss.Color("#0CB37F"), baseCyan), + Argument: c(charmtone.Charcoal, baseWhite), + Description: c(charmtone.Charcoal, baseWhite), + FlagDefault: c(charmtone.Smoke, charmtone.Squid), + QuotedString: c(charmtone.Coral, baseCyan), + ErrorHeader: [2]color.Color{ + charmtone.Butter, + charmtone.Sriracha, + }, + } +} + +// AnsiColorScheme is a ANSI colorscheme. +func AnsiColorScheme(c lipgloss.LightDarkFunc) fang.ColorScheme { + base := c(lipgloss.Black, lipgloss.White) + + return fang.ColorScheme{ + Base: base, + Title: lipgloss.White, + Description: base, + Comment: c(lipgloss.BrightWhite, lipgloss.BrightBlack), + Flag: lipgloss.White, + FlagDefault: lipgloss.White, + Command: lipgloss.White, + QuotedString: lipgloss.White, + Argument: base, + Help: base, + Dash: base, + ErrorHeader: [2]color.Color{lipgloss.Black, lipgloss.Red}, + ErrorDetails: lipgloss.Red, + } +} + +func SynthwaveColorScheme(c lipgloss.LightDarkFunc) fang.ColorScheme { + return fang.ColorScheme{ + Base: c(charmtone.Charcoal, charmtone.Cheeky), + Title: charmtone.Grape, + Codeblock: c(charmtone.Salt, lipgloss.Color("#2F2E36")), + Program: c(charmtone.Malibu, charmtone.Grape), + Command: c(charmtone.Pony, charmtone.Cheeky), + DimmedArgument: c(charmtone.Squid, charmtone.Oyster), + Comment: c(charmtone.Squid, lipgloss.Color("#747282")), + Flag: c(lipgloss.Color("#0CB37F"), charmtone.Cheeky), + Argument: c(charmtone.Charcoal, charmtone.Ash), + Description: c(charmtone.Charcoal, charmtone.Ash), + FlagDefault: c(charmtone.Smoke, charmtone.Squid), + QuotedString: c(charmtone.Coral, charmtone.Cheeky), + ErrorHeader: [2]color.Color{ + charmtone.Butter, + charmtone.Sriracha, + }, + } +} diff --git a/lets.yaml b/lets.yaml index 613dca82..859e77de 100644 --- a/lets.yaml +++ b/lets.yaml @@ -36,12 +36,16 @@ commands: test-unit: description: Run unit tests depends: [build-lets-image] - cmd: - - docker - - compose - - run - - --rm - - test + options: | + Usage: lets test-unit [--update-golden] + Options: + --update-golden Regenerate golden test snapshot files + cmd: | + if [[ -n "${LETSOPT_UPDATE_GOLDEN}" ]]; then + docker compose run --rm test gotestsum --format testname -- ./internal/cmd/... -update + else + docker compose run --rm test gotestsum --format testname -- ./... -coverprofile=coverage.out + fi test-bats: description: Run bats tests diff --git a/tests/command_cmd.bats b/tests/command_cmd.bats index 9533ddc7..d10cae54 100644 --- a/tests/command_cmd.bats +++ b/tests/command_cmd.bats @@ -43,9 +43,9 @@ setup() { # as there is no guarantee in which order cmds runs # we can not guarantee that all commands will run and complete. # But error message must be in the output. - assert_output --partial "lets: command failed:" - assert_output --partial "└─ cmd-as-map-error <-- failed here" - assert_output --partial "lets: exit status 2" + assert_output --partial "cmd-as-map-error" + assert_output --partial "<-- failed here" + assert_output --partial "Exit status 2" } @test "command_cmd: cmd-as-map must propagate env" { diff --git a/tests/command_docopt_cmd_placeholder.bats b/tests/command_docopt_cmd_placeholder.bats index 5f512c6b..8a8163aa 100644 --- a/tests/command_docopt_cmd_placeholder.bats +++ b/tests/command_docopt_cmd_placeholder.bats @@ -19,6 +19,6 @@ setup() { run lets cmd-2 posarg --config=some_path assert_failure - assert_line --partial "failed to parse docopt options for cmd cmd-2" - assert_line --partial "Usage: lets cmd [] [--config=]" + assert_output --partial "Failed to parse docopt options for cmd cmd-2" + assert_output --partial "Usage: lets cmd [] [--config=]" } diff --git a/tests/command_group.bats b/tests/command_group.bats index c0e5d4a5..8cdb6f72 100644 --- a/tests/command_group.bats +++ b/tests/command_group.bats @@ -6,63 +6,17 @@ setup() { cd ./tests/command_group } -HELP_MESSAGE=$(cat <] -EOF -) - @test "command_help: help contains description and options" { run lets help test assert_success - assert_output "${TEST_HELP_MESSAGE}" + assert_output --partial "Run tests" } - -TEST2_HELP_MESSAGE=$(cat <] -EOF -) - @test "command_help: must add new line between description and options" { run lets help test2 assert_success - assert_output "${TEST2_HELP_MESSAGE}" -} \ No newline at end of file + assert_output --partial "Run tests" +} diff --git a/tests/command_not_found.bats b/tests/command_not_found.bats index 5ecfd0cf..844d2b9e 100644 --- a/tests/command_not_found.bats +++ b/tests/command_not_found.bats @@ -17,7 +17,6 @@ setup() { @test "command_not_found: suggest root command for close typo" { run lets slef assert_failure 2 - assert_output --partial 'unknown command "slef" for "lets"' assert_output --partial 'Did you mean this?' assert_output --partial 'self' } @@ -25,7 +24,6 @@ setup() { @test "command_not_found: suggest self subcommand for close typo" { run lets self ls assert_failure 2 - assert_output --partial 'unknown command "ls" for "lets self"' assert_output --partial 'Did you mean this?' assert_output --partial 'lsp' } @@ -33,13 +31,11 @@ setup() { @test "command_not_found: no suggestions for completely unrelated command" { run lets zzzznotacommand assert_failure 2 - assert_output --partial 'unknown command "zzzznotacommand" for "lets"' refute_output --partial 'Did you mean this?' } @test "command_not_found: no suggestions for completely unrelated self subcommand" { run lets self zzzznotacommand assert_failure 2 - assert_output --partial 'unknown command "zzzznotacommand" for "lets self"' refute_output --partial 'Did you mean this?' } diff --git a/tests/command_options.bats b/tests/command_options.bats index 2ca800dc..0fbe2800 100644 --- a/tests/command_options.bats +++ b/tests/command_options.bats @@ -108,26 +108,18 @@ setup() { run lets test-options --kv-opt assert_failure - assert_line --index 0 "lets: command failed:" - assert_line --index 1 " └─ test-options <-- failed here" - assert_line --index 2 "lets: failed to parse docopt options for cmd test-options: --kv-opt requires argument" - assert_line --index 3 "Usage:" - assert_line --index 4 " lets test-options [--kv-opt=] [--bool-opt] [--attr=...] [...]" - assert_line --index 5 "Options:" - assert_line --index 6 " ... Positional args in the end" - assert_line --index 7 " --bool-opt, -b Boolean opt" - assert_line --index 8 " --kv-opt=, -K Key value opt" - assert_line --index 9 " --attr=... Repeated kv args" + assert_output --partial "Failed to parse docopt options for cmd test-options: --kv-opt requires argument" + assert_output --partial "test-options" + assert_output --partial "<-- failed here" } @test "command_options: wrong usage" { run lets options-wrong-usage assert_failure - assert_line --index 0 "lets: command failed:" - assert_line --index 1 " └─ options-wrong-usage <-- failed here" - assert_line --index 2 "lets: failed to parse docopt options for cmd options-wrong-usage: unknown option or argument: options-wrong-usage" - assert_line --index 3 "Usage: lets options-wrong-usage-xxx" + assert_output --partial "Failed to parse docopt options for cmd options-wrong-usage" + assert_output --partial "options-wrong-usage" + assert_output --partial "<-- failed here" } @test "command_options: should not break json argument" { diff --git a/tests/completion.bats b/tests/completion.bats index 930387cc..df5dfbf4 100644 --- a/tests/completion.bats +++ b/tests/completion.bats @@ -13,14 +13,14 @@ setup() { LETS_CONFIG_DIR="no_lets_file" run lets completion assert_success - assert_line --index 0 "Generates completion scripts for bash, zsh" + assert_output --partial "Generates completion scripts for bash, zsh" [[ ! -d .lets ]] } @test "completion: should return completion if lets.yaml exists" { run lets completion assert_success - assert_line --index 0 "Generates completion scripts for bash, zsh" + assert_output --partial "Generates completion scripts for bash, zsh" [[ -d .lets ]] } diff --git a/tests/config_version.bats b/tests/config_version.bats index 05e2d148..2766c882 100644 --- a/tests/config_version.bats +++ b/tests/config_version.bats @@ -18,7 +18,7 @@ teardown() { LETS_CONFIG=lets-with-version-0.0.1.yaml run ./lets assert_success - assert_line --index 0 "A CLI task runner" + assert_output --partial "A CLI task runner" } @test "config_version: if config version greater than lets version - fail - require upgrade" { @@ -31,5 +31,5 @@ teardown() { @test "config_version: no version specified" { LETS_CONFIG=lets-without-version.yaml run ./lets assert_success - assert_line --index 0 "A CLI task runner" + assert_output --partial "A CLI task runner" } diff --git a/tests/default_env.bats b/tests/default_env.bats index c21014c8..a72b5959 100644 --- a/tests/default_env.bats +++ b/tests/default_env.bats @@ -61,7 +61,7 @@ setup() { LETS_CONFIG_DIR=./a run lets print-workdir assert_failure - assert_line --index 0 "lets: command failed:" - assert_line --index 1 " └─ print-workdir <-- failed here" - assert_line --index 2 "lets: chdir ${TEST_DIR}/b: no such file or directory" + assert_output --partial "print-workdir" + assert_output --partial "<-- failed here" + assert_output --partial "chdir ${TEST_DIR}/b: no such file or directory" } diff --git a/tests/dependency_failure_tree.bats b/tests/dependency_failure_tree.bats index 3da19553..a35d1981 100644 --- a/tests/dependency_failure_tree.bats +++ b/tests/dependency_failure_tree.bats @@ -9,17 +9,9 @@ setup() { @test "dependency_failure_tree: shows full 3-level chain on failure" { run env NO_COLOR=1 lets deploy assert_failure - assert_line --index 0 "lets: command failed:" - assert_line --index 1 " └─ deploy" - assert_line --index 2 " └─ build" - assert_line --index 3 " └─ lint <-- failed here" - assert_line --index 4 "lets: exit status 1" } @test "dependency_failure_tree: single node when no depends" { run env NO_COLOR=1 lets lint assert_failure - assert_line --index 0 "lets: command failed:" - assert_line --index 1 " └─ lint <-- failed here" - assert_line --index 2 "lets: exit status 1" } diff --git a/tests/help.bats b/tests/help.bats index e9246e50..91fc6fd6 100644 --- a/tests/help.bats +++ b/tests/help.bats @@ -6,69 +6,6 @@ setup() { cd ./tests/help } -HELP_MESSAGE=$(cat <