├── .dockerignore ├── main_test.go ├── testdata ├── etc │ ├── not-an-ini-file │ └── os-release └── bin │ ├── bad-version │ ├── tmux │ ├── semver │ └── ssh ├── logo.png ├── .gitignore ├── bin ├── list-goos.sh ├── container-build ├── list-goarch.sh ├── test-with-coverage ├── bump-version.sh ├── install-go-linters ├── install-linters └── test-everything ├── .github ├── FUNDING.yml ├── dependabot.yml └── workflows │ ├── release.yml │ └── test.yml ├── typos.toml ├── attr └── attr.go ├── docker-compose.yml ├── ops └── ops.go ├── test ├── user.bats ├── is.bats ├── arch.bats ├── there.bats ├── os.bats ├── known │ ├── no-battery.bats │ └── battery.bats ├── battery.bats ├── no-battery.bats ├── known.bats ├── cli.bats └── var.bats ├── arch.go ├── CONTRIBUTING.md ├── version ├── version_test.go └── version.go ├── user_test.go ├── fso.go ├── types └── types.go ├── audio.go ├── there_test.go ├── user.go ├── examples └── rbenv.sh ├── reader ├── reader_test.go └── reader.go ├── age └── age.go ├── os ├── os_test.go └── os.go ├── LICENSE-MIT ├── mac ├── mac_test.go └── mac.go ├── command └── command.go ├── arch_test.go ├── precious.toml ├── audio ├── audio_unsupported.go └── audio.go ├── fso_test.go ├── var.go ├── battery.go ├── go.mod ├── os.go ├── .golangci.yaml ├── main.go ├── .goreleaser.yaml ├── there.go ├── CLAUDE.md ├── battery └── battery.go ├── os_test.go ├── CHANGELOG.md ├── cli.go ├── parser ├── parser.go └── parser_test.go ├── cli_test.go ├── go.sum ├── compare ├── compare.go └── compare_test.go ├── api.go ├── known_test.go ├── known.go └── LICENSE-APACHE /.dockerignore: -------------------------------------------------------------------------------- 1 | is 2 | -------------------------------------------------------------------------------- /main_test.go: -------------------------------------------------------------------------------- 1 | package main 2 | -------------------------------------------------------------------------------- /testdata/etc/not-an-ini-file: -------------------------------------------------------------------------------- 1 | {} 2 | -------------------------------------------------------------------------------- /logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/oalders/is/HEAD/logo.png -------------------------------------------------------------------------------- /testdata/bin/bad-version: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | echo "X3v" 4 | exit 0 5 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | c.out 2 | codecov.out 3 | coverage 4 | coverage.out 5 | dist/ 6 | is 7 | -------------------------------------------------------------------------------- /bin/list-goos.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | go tool dist list | cut -d/ -f1 | uniq | sort 4 | -------------------------------------------------------------------------------- /bin/container-build: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | set -eux 4 | 5 | go build -o /usr/local/bin/is 6 | -------------------------------------------------------------------------------- /bin/list-goarch.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | go tool dist list | cut -d/ -f2 | sort | uniq 4 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | # These are supported funding model platforms 2 | 3 | github: [oalders] 4 | -------------------------------------------------------------------------------- /typos.toml: -------------------------------------------------------------------------------- 1 | [default.extend-words] 2 | abd = "abd" 3 | sur = "sur" 4 | 5 | [files] 6 | extend-exclude = [] 7 | -------------------------------------------------------------------------------- /bin/test-with-coverage: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | set -e -u -o pipefail 4 | 5 | go test -coverprofile=c.out ./... && go tool cover -html=c.out 6 | -------------------------------------------------------------------------------- /testdata/bin/tmux: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | set -e -u -o pipefail 4 | 5 | if [[ $# -eq 1 ]] && [[ $1 = "-V" ]]; then 6 | echo "tmux 3.3a" 7 | fi 8 | -------------------------------------------------------------------------------- /testdata/bin/semver: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | set -e -u -o pipefail 4 | 5 | if [[ $# -eq 1 ]] && [[ $1 = "--version" ]]; then 6 | echo "1.2.3" 7 | fi 8 | -------------------------------------------------------------------------------- /bin/bump-version.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | set -eux -o pipefail 4 | 5 | from="$1" 6 | to="$2" 7 | 8 | perl -i -pE "s/$from/$to/g" README.md main.go test/is.bats 9 | -------------------------------------------------------------------------------- /attr/attr.go: -------------------------------------------------------------------------------- 1 | // package attr exports attribute constants 2 | package attr 3 | 4 | const ( 5 | Name = "name" 6 | Version = "version" 7 | VersionCodename = "version-codename" 8 | ) 9 | -------------------------------------------------------------------------------- /testdata/bin/ssh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | set -e -u -o pipefail 4 | 5 | echo "$@" 6 | 7 | if [[ $# -eq 1 ]] && [[ $1 = "-V" ]]; then 8 | >&2 echo "OpenSSH_9.0p1, LibreSSL 3.3.6" 9 | fi 10 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | --- 2 | services: 3 | dev: 4 | image: golang:1.24.4-alpine 5 | volumes: 6 | - .:/workspace 7 | working_dir: /workspace 8 | stdin_open: true 9 | tty: true 10 | -------------------------------------------------------------------------------- /ops/ops.go: -------------------------------------------------------------------------------- 1 | package ops 2 | 3 | const ( 4 | Eq = "eq" 5 | Gt = "gt" 6 | Gte = "gte" 7 | In = "in" 8 | Like = "like" 9 | Lt = "lt" 10 | Lte = "lte" 11 | Ne = "ne" 12 | Unlike = "unlike" 13 | ) 14 | -------------------------------------------------------------------------------- /test/user.bats: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bats 2 | 3 | bats_require_minimum_version 1.5.0 4 | 5 | @test "is user xxx" { 6 | run ! ./is user xxx 7 | } 8 | 9 | @test "is user sudoer --debug" { 10 | run ./is user sudoer --debug || true 11 | } 12 | -------------------------------------------------------------------------------- /test/is.bats: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bats 2 | 3 | @test "is --version" { 4 | run ./is --version 5 | [ "$status" -eq 0 ] 6 | [ "$output" = "0.11.0" ] 7 | } 8 | 9 | @test "is --help" { 10 | ./is --help 11 | } 12 | 13 | @test "is -h" { 14 | ./is -h 15 | } 16 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: "github-actions" 4 | directory: "/" 5 | schedule: 6 | # Check for updates to GitHub Actions every week 7 | interval: "weekly" 8 | - package-ecosystem: gomod 9 | directory: "/" 10 | schedule: 11 | interval: daily 12 | -------------------------------------------------------------------------------- /bin/install-go-linters: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | set -eux -o pipefail 4 | 5 | go install mvdan.cc/gofumpt@latest 6 | go install golang.org/x/tools/cmd/goimports@latest 7 | 8 | curl -sSfL https://raw.githubusercontent.com/golangci/golangci-lint/master/install.sh | 9 | sh -s -- -b "$(go env GOPATH)/bin" latest 10 | -------------------------------------------------------------------------------- /arch.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "runtime" 5 | 6 | "github.com/oalders/is/compare" 7 | "github.com/oalders/is/types" 8 | ) 9 | 10 | // Run "is arch ...". 11 | func (r *ArchCmd) Run(ctx *types.Context) error { 12 | success, err := compare.Strings(ctx, r.Op, runtime.GOARCH, r.Val) 13 | ctx.Success = success 14 | return err 15 | } 16 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # CONTRIBUTING 2 | 3 | ## Working in Docker 4 | 5 | ```shell 6 | docker compose run --rm dev sh 7 | ``` 8 | 9 | ## Building inside the container 10 | 11 | ```shell 12 | ./bin/container-build 13 | ``` 14 | 15 | `is` is now in your `$PATH`. 16 | 17 | ## Testing inside the container 18 | 19 | ```shell 20 | apk add bash 21 | go test ./... 22 | ``` 23 | -------------------------------------------------------------------------------- /version/version_test.go: -------------------------------------------------------------------------------- 1 | package version_test 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/oalders/is/version" 7 | "github.com/stretchr/testify/assert" 8 | ) 9 | 10 | func TestCompareCLIVersions(t *testing.T) { 11 | t.Parallel() 12 | _, err := version.NewVersion("3.3") 13 | assert.NoError(t, err) 14 | 15 | _, err = version.NewVersion("x") 16 | assert.Error(t, err) 17 | } 18 | -------------------------------------------------------------------------------- /user_test.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "context" 5 | "testing" 6 | 7 | "github.com/oalders/is/types" 8 | "github.com/stretchr/testify/assert" 9 | ) 10 | 11 | func TestSudoer(t *testing.T) { 12 | t.Parallel() 13 | ctx := types.Context{ 14 | Context: context.Background(), 15 | Debug: true, 16 | } 17 | cmd := UserCmd{} 18 | cmd.Sudoer = "sudoer" 19 | err := cmd.Run(&ctx) 20 | assert.NoError(t, err) 21 | } 22 | -------------------------------------------------------------------------------- /fso.go: -------------------------------------------------------------------------------- 1 | // Package main contains the logic for the "fso" command 2 | package main 3 | 4 | import ( 5 | "errors" 6 | 7 | "github.com/oalders/is/types" 8 | ) 9 | 10 | func (r *FSOCmd) Run(ctx *types.Context) error { 11 | if r.Age.Name != "" { 12 | success, err := runAge(ctx, r.Age.Name, r.Age.Op, r.Age.Val, r.Age.Unit) 13 | ctx.Success = success 14 | return err 15 | } 16 | return errors.New("unimplemented command") 17 | } 18 | -------------------------------------------------------------------------------- /testdata/etc/os-release: -------------------------------------------------------------------------------- 1 | NAME="Ubuntu" 2 | VERSION="18.04.6 LTS (Bionic Beaver)" 3 | ID=ubuntu 4 | ID_LIKE=debian 5 | PRETTY_NAME="Ubuntu 18.04.6 LTS" 6 | VERSION_ID="18.04" 7 | HOME_URL="https://www.ubuntu.com/" 8 | SUPPORT_URL="https://help.ubuntu.com/" 9 | BUG_REPORT_URL="https://bugs.launchpad.net/ubuntu/" 10 | PRIVACY_POLICY_URL="https://www.ubuntu.com/legal/terms-and-policies/privacy-policy" 11 | VERSION_CODENAME=bionic 12 | UBUNTU_CODENAME=bionic 13 | -------------------------------------------------------------------------------- /version/version.go: -------------------------------------------------------------------------------- 1 | // package version creates version objects from strings 2 | package version 3 | 4 | import ( 5 | "fmt" 6 | 7 | goversion "github.com/hashicorp/go-version" 8 | ) 9 | 10 | func NewVersion(vstring string) (*goversion.Version, error) { 11 | // func NewVersion(x string) (string,error) {` 12 | got, err := goversion.NewVersion(vstring) 13 | if err != nil { 14 | err = fmt.Errorf("parse version from \"%s\": %w", vstring, err) 15 | } 16 | return got, err 17 | } 18 | -------------------------------------------------------------------------------- /test/arch.bats: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bats 2 | 3 | bats_require_minimum_version 1.5.0 4 | 5 | @test "is arch ne xxx" { 6 | ./is arch ne xxx 7 | } 8 | 9 | @test "is arch unlike xxx" { 10 | ./is arch unlike xxx 11 | } 12 | 13 | @test 'is arch like ".*"' { 14 | ./is arch like ".*" 15 | } 16 | 17 | @test 'is arch eq beos' { 18 | run ! ./is arch eq beos 19 | } 20 | 21 | @test 'is arch in' { 22 | run ./is arch in amd64,arm,arm64 23 | } 24 | 25 | @test 'in trims whitespace' { 26 | run ./is arch in " amd64 , arm , arm64 " 27 | } 28 | -------------------------------------------------------------------------------- /bin/install-linters: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | set -eux -o pipefail 4 | 5 | go install mvdan.cc/gofumpt@latest 6 | go install golang.org/x/tools/cmd/goimports@latest 7 | 8 | curl -sSfL https://raw.githubusercontent.com/golangci/golangci-lint/master/install.sh | 9 | sh -s -- -b "$(go env GOPATH)/bin" latest 10 | 11 | env | sort 12 | mkdir -p ~/.local/bin 13 | 14 | curl --silent --location \ 15 | https://raw.githubusercontent.com/houseabsolute/ubi/master/bootstrap/bootstrap-ubi.sh | 16 | TARGET=~/.local/bin sh 17 | 18 | ubi --project houseabsolute/omegasort --in ~/.local/bin 19 | ubi --project houseabsolute/precious --in ~/.local/bin 20 | -------------------------------------------------------------------------------- /types/types.go: -------------------------------------------------------------------------------- 1 | // Package types is for is-specific types 2 | package types //nolint:revive 3 | 4 | import "context" 5 | 6 | // Context type tracks top level debugging flag. 7 | type Context struct { 8 | Context context.Context //nolint:containedctx 9 | Debug bool 10 | Success bool 11 | } 12 | 13 | //nolint:tagliatelle 14 | type OSRelease struct { 15 | ID string `json:"id,omitempty"` 16 | IDLike string `json:"id-like,omitempty"` 17 | Name string `json:"name"` 18 | PrettyName string `json:"pretty-name,omitempty"` 19 | Version string `json:"version,omitempty"` 20 | VersionCodeName string `json:"version-codename,omitempty"` 21 | } 22 | -------------------------------------------------------------------------------- /audio.go: -------------------------------------------------------------------------------- 1 | // This file handles battery info checks 2 | package main 3 | 4 | import ( 5 | "fmt" 6 | 7 | "github.com/oalders/is/audio" 8 | "github.com/oalders/is/compare" 9 | "github.com/oalders/is/types" 10 | ) 11 | 12 | // Run "is audio...". 13 | func (r *AudioCmd) Run(ctx *types.Context) error { 14 | summary, err := audio.Summary(ctx) 15 | ctx.Success = false 16 | 17 | if err != nil { 18 | return err 19 | } 20 | 21 | if r.Attr == "muted" { 22 | ctx.Success = summary.Muted 23 | return nil 24 | } 25 | 26 | ok, err := compare.Integers(ctx, r.Op, fmt.Sprintf("%d", summary.Level), r.Val) 27 | if err != nil { 28 | return err 29 | } 30 | ctx.Success = ok 31 | return nil 32 | } 33 | -------------------------------------------------------------------------------- /bin/test-everything: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | set -eux -o pipefail 4 | 5 | GOCOVERDIR=$(mktemp -d) 6 | export GOCOVERDIR 7 | 8 | # Run tests 9 | go test -v -timeout 2m -coverprofile=coverage.out ./... 10 | go build -cover 11 | bats -r test 12 | 13 | # Merge and analyze coverage data 14 | mkdir -p coverage 15 | go tool covdata merge -i="$GOCOVERDIR" -o=coverage 16 | go tool covdata textfmt -i=coverage -o=coverage/coverage.out 17 | 18 | # Generate reports 19 | go tool cover -func=coverage/coverage.out 20 | go tool cover -html=coverage/coverage.out -o coverage/coverage.html 21 | 22 | # Create a copy specifically for Codecov 23 | cp coverage/coverage.out codecov.out 24 | 25 | # Cleanup 26 | rm -rf "$GOCOVERDIR" 27 | -------------------------------------------------------------------------------- /there_test.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "context" 5 | "testing" 6 | 7 | "github.com/oalders/is/types" 8 | "github.com/stretchr/testify/assert" 9 | ) 10 | 11 | func TestThereCmd(t *testing.T) { 12 | t.Parallel() 13 | { 14 | ctx := types.Context{ 15 | Context: context.Background(), 16 | Debug: true, 17 | } 18 | cmd := ThereCmd{Name: "cat"} 19 | err := cmd.Run(&ctx) 20 | assert.NoError(t, err) 21 | assert.True(t, ctx.Success) 22 | } 23 | { 24 | ctx := types.Context{ 25 | Context: context.Background(), 26 | Debug: true, 27 | } 28 | cmd := ThereCmd{Name: "catzzzzz"} 29 | err := cmd.Run(&ctx) 30 | assert.NoError(t, err) 31 | assert.False(t, ctx.Success) 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /user.go: -------------------------------------------------------------------------------- 1 | // Package main contains the logic for the "user" command 2 | package main 3 | 4 | import ( 5 | "errors" 6 | "log" 7 | "os/exec" 8 | "strings" 9 | 10 | "github.com/oalders/is/command" 11 | "github.com/oalders/is/types" 12 | ) 13 | 14 | // Run "is user ...". 15 | func (r *UserCmd) Run(ctx *types.Context) error { 16 | if ctx.Debug { 17 | log.Printf("Running \"sudo -n true\"\n") 18 | } 19 | cmd := exec.CommandContext(ctx.Context, "sudo", "-n", "true") 20 | output, err := command.Output(cmd, "stderr") 21 | if err != nil { 22 | if !errors.Is(err, exec.ErrNotFound) { 23 | return err 24 | } 25 | return nil 26 | } 27 | if strings.Contains(output, "password") { 28 | return nil 29 | } 30 | 31 | ctx.Success = true 32 | return nil 33 | } 34 | -------------------------------------------------------------------------------- /examples/rbenv.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | set -eu -o pipefail 4 | version=3.3.0 5 | 6 | if is os name ne 'darwin'; then 7 | exit 8 | fi 9 | 10 | if ! is there rbenv; then 11 | brew install rbenv 12 | fi 13 | 14 | # Might need to initialize rbenv in the shell 15 | if [ -z "${RBENV_SHELL:-}" ]; then 16 | eval "$(rbenv init - bash)" 17 | fi 18 | 19 | if is cli output stdout rbenv --arg version like "^$version\b"; then 20 | echo "Ruby version $version is already installed" 21 | exit 22 | fi 23 | 24 | if ! is cli output stdout rbenv --arg versions like "\b$version\b" --debug; then 25 | rbenv install $version 26 | fi 27 | 28 | rbenv global $version 29 | 30 | if ! is cli version ruby eq $version; then 31 | echo "Ruby version $version is not available" 32 | fi 33 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | --- 2 | name: goreleaser 3 | 4 | on: 5 | push: 6 | tags: 7 | - '*' 8 | workflow_dispatch: 9 | 10 | permissions: 11 | contents: write 12 | 13 | jobs: 14 | test: 15 | uses: ./.github/workflows/test.yml 16 | 17 | goreleaser: 18 | runs-on: ubuntu-latest 19 | needs: test 20 | steps: 21 | - name: Checkout 22 | uses: actions/checkout@v6 23 | with: 24 | fetch-depth: 0 25 | - name: Set up Go 26 | uses: actions/setup-go@v6 27 | with: 28 | go-version: 1.23.0 29 | - name: Run GoReleaser 30 | uses: goreleaser/goreleaser-action@v6 31 | with: 32 | # either 'goreleaser' (default) or 'goreleaser-pro' 33 | distribution: goreleaser 34 | version: '~> v2' 35 | args: release --clean 36 | env: 37 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 38 | -------------------------------------------------------------------------------- /reader/reader_test.go: -------------------------------------------------------------------------------- 1 | package reader_test 2 | 3 | import ( 4 | "context" 5 | "testing" 6 | 7 | "github.com/oalders/is/reader" 8 | "github.com/oalders/is/types" 9 | "github.com/stretchr/testify/assert" 10 | ) 11 | 12 | func TestMaybeReadINI(t *testing.T) { 13 | t.Parallel() 14 | ctx := types.Context{ 15 | Context: context.Background(), 16 | } 17 | { 18 | release, err := reader.MaybeReadINI(&ctx, "../testdata/etc/os-release") 19 | assert.NoError(t, err) 20 | assert.Equal(t, "18.04", release.Version) 21 | } 22 | { 23 | // if the file does not exist on this system, that's not an error 24 | release, err := reader.MaybeReadINI(&ctx, "../testdata/etc/os-releasezzz") 25 | assert.NoError(t, err) 26 | assert.Nil(t, release) 27 | } 28 | { 29 | ctx.Debug = true 30 | // if the file cannot be parsed, that's an error 31 | release, err := reader.MaybeReadINI(&ctx, "../testdata/etc/not-an-ini-file") 32 | assert.Error(t, err) 33 | assert.Nil(t, release) 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /age/age.go: -------------------------------------------------------------------------------- 1 | package age 2 | 3 | import ( 4 | "fmt" 5 | "strconv" 6 | "time" 7 | ) 8 | 9 | func StringToDuration(val, rawUnit string) (*time.Duration, error) { 10 | units := map[string]string{ 11 | "s": "s", 12 | "second": "s", 13 | "seconds": "s", 14 | "m": "m", 15 | "minute": "m", 16 | "minutes": "m", 17 | "h": "h", 18 | "hour": "h", 19 | "hours": "h", 20 | "d": "d", 21 | "day": "d", 22 | "days": "d", 23 | } 24 | 25 | unit := units[rawUnit] 26 | unitMultiplier := -1 27 | if unit == "d" { 28 | unitMultiplier = -24 29 | unit = "h" 30 | } 31 | 32 | value, err := strconv.Atoi(val) 33 | if err != nil { 34 | return nil, fmt.Errorf("%s does not appear to be an integer: %w", val, err) 35 | } 36 | durationString := fmt.Sprintf("%d%s", value*unitMultiplier, unit) 37 | dur, err := time.ParseDuration(durationString) 38 | if err != nil { 39 | err = fmt.Errorf("cannot parse duration: %w", err) 40 | } 41 | return &dur, err 42 | } 43 | -------------------------------------------------------------------------------- /test/there.bats: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bats 2 | 3 | bats_require_minimum_version 1.5.0 4 | 5 | tmux=./testdata/bin/tmux 6 | 7 | @test "is there tmux" { 8 | ./is there $tmux 9 | run ! ./is there tmuxxx 10 | } 11 | 12 | @test "non-zero when cli does not exist" { 13 | ./is there $tmux 14 | run ! ./is there tmuxxx 15 | } 16 | 17 | @test "is there bash" { 18 | run ./is there bash --verbose 19 | echo $status 20 | [ "$status" -eq 0 ] 21 | } 22 | 23 | @test "is there bash --verbose" { 24 | run ./is there bash --verbose 25 | echo $status 26 | [ "$status" -eq 0 ] 27 | } 28 | 29 | @test "is there bash --json" { 30 | run ./is there bash --json 31 | echo $status 32 | [ "$status" -eq 0 ] 33 | } 34 | 35 | @test "is there bash --all" { 36 | run ./is there bash --all 37 | echo $status 38 | [ "$status" -eq 0 ] 39 | } 40 | 41 | @test "is there bash --json --all" { 42 | run ./is there bash --json --all 43 | echo $status 44 | [ "$status" -eq 0 ] 45 | } 46 | -------------------------------------------------------------------------------- /test/os.bats: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bats 2 | 3 | bats_require_minimum_version 1.5.0 4 | 5 | @test "is os" { 6 | ./is os name like ".*" 7 | run ! ./is os name unlike ".*" 8 | } 9 | 10 | @test "is os name in" { 11 | ./is os name in darwin,linux 12 | } 13 | 14 | @test "is os version gt 0 --debug" { 15 | ./is os version gt 0 --debug 16 | } 17 | 18 | @test "is os version --major gt 0" { 19 | ./is os version --major gt 0 20 | } 21 | 22 | @test "is os version --minor gt 0" { 23 | ./is os version --minor gt 0 24 | } 25 | 26 | @test "is os id --major gt 0" { 27 | run ./is os id --major gt 0 28 | [[ $status -ne 0 ]] 29 | [[ "$output" == *"--major can only be used with version"* ]] 30 | } 31 | 32 | @test "is os id --minor gt 0" { 33 | run ./is os id --minor gt 0 34 | [[ $status -ne 0 ]] 35 | [[ "$output" == *"--minor can only be used with version"* ]] 36 | } 37 | 38 | @test "is os id --patch gt 0" { 39 | run ./is os id --patch gt 0 40 | [[ $status -ne 0 ]] 41 | [[ "$output" == *"--patch can only be used with version"* ]] 42 | } 43 | -------------------------------------------------------------------------------- /os/os_test.go: -------------------------------------------------------------------------------- 1 | package os_test 2 | 3 | import ( 4 | "context" 5 | "runtime" 6 | "testing" 7 | 8 | "github.com/oalders/is/attr" 9 | "github.com/oalders/is/os" 10 | "github.com/oalders/is/types" 11 | "github.com/stretchr/testify/assert" 12 | ) 13 | 14 | func TestOSInfo(t *testing.T) { 15 | t.Parallel() 16 | tests := []string{"name", attr.Version} 17 | 18 | for _, attr := range tests { 19 | ctx := &types.Context{ 20 | Context: context.Background(), 21 | Debug: true, 22 | } 23 | found, err := os.Info(ctx, attr) 24 | assert.NoError(t, err, attr) 25 | assert.NotEmpty(t, found, attr) 26 | } 27 | 28 | // id-like not present in Debian 11, so can't be in a blanket Linux test 29 | if runtime.GOOS == "linux" { 30 | tests := []string{"id", "pretty-name"} 31 | 32 | for _, attr := range tests { 33 | ctx := &types.Context{ 34 | Context: context.Background(), 35 | Debug: true, 36 | } 37 | found, err := os.Info(ctx, attr) 38 | assert.NoError(t, err, attr) 39 | assert.NotEmpty(t, found, attr) 40 | } 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /LICENSE-MIT: -------------------------------------------------------------------------------- 1 | Permission is hereby granted, free of charge, to any person obtaining a copy of 2 | this software and associated documentation files (the "Software"), to deal in 3 | the Software without restriction, including without limitation the rights to 4 | use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies 5 | of the Software, and to permit persons to whom the Software is furnished to do 6 | so, subject to the following conditions: 7 | 8 | The above copyright notice and this permission notice shall be included in all 9 | copies or substantial portions of the Software. 10 | 11 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 12 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 13 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 14 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 15 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 16 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 17 | SOFTWARE. 18 | -------------------------------------------------------------------------------- /mac/mac_test.go: -------------------------------------------------------------------------------- 1 | package mac_test 2 | 3 | import ( 4 | "context" 5 | "runtime" 6 | "testing" 7 | 8 | "github.com/oalders/is/mac" 9 | "github.com/oalders/is/types" 10 | "github.com/stretchr/testify/assert" 11 | ) 12 | 13 | func TestCodeName(t *testing.T) { 14 | t.Parallel() 15 | tests := [][]string{ 16 | {"13.1", "ventura"}, 17 | {"12.2", "monterey"}, 18 | {"11.2", "big sur"}, 19 | {"10.15", "catalina"}, 20 | {"10.14", "mojave"}, 21 | {"10.13", "high sierra"}, 22 | {"10.12", "sierra"}, 23 | {"10.11", "el capitan"}, 24 | {"10.10", "yosemite"}, 25 | {"10.9", "mavericks"}, 26 | {"10.8", "mountain lion"}, 27 | {"10.7", ""}, 28 | {"9.0", ""}, 29 | {"-1", ""}, 30 | } 31 | 32 | for _, v := range tests { 33 | assert.Equal(t, v[1], mac.CodeName(v[0])) 34 | } 35 | } 36 | 37 | func TestVersion(t *testing.T) { 38 | t.Parallel() 39 | ctx := types.Context{Context: context.Background()} 40 | version, err := mac.Version(&ctx) 41 | if runtime.GOOS == "darwin" { 42 | assert.NotEmpty(t, version) 43 | assert.NoError(t, err) 44 | } else { 45 | assert.Empty(t, version) 46 | assert.Error(t, err) 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /command/command.go: -------------------------------------------------------------------------------- 1 | package command 2 | 3 | import ( 4 | "fmt" 5 | "io" 6 | "os/exec" 7 | "strings" 8 | ) 9 | 10 | func Output(cmd *exec.Cmd, stream string) (string, error) { 11 | var pipe io.ReadCloser 12 | var err error 13 | var output []byte 14 | 15 | switch stream { 16 | case "stdout": 17 | pipe, err = cmd.StdoutPipe() 18 | if err != nil { 19 | return "", fmt.Errorf("stdout pipe: %w", err) 20 | } 21 | defer pipe.Close() 22 | case "stderr": 23 | pipe, err = cmd.StderrPipe() 24 | if err != nil { 25 | return "", fmt.Errorf("stderr pipe: %w", err) 26 | } 27 | defer pipe.Close() 28 | case "combined": 29 | output, err = cmd.CombinedOutput() 30 | if err != nil { 31 | return "", fmt.Errorf("combined output: %w", err) 32 | } 33 | } 34 | 35 | // This means it's not combined output 36 | if len(output) == 0 { 37 | if err := cmd.Start(); err != nil { 38 | return "", fmt.Errorf("starting command: %w", err) 39 | } 40 | 41 | output, err = io.ReadAll(pipe) 42 | if err != nil { 43 | return "", fmt.Errorf("read output: %w", err) 44 | } 45 | } 46 | return strings.TrimSpace(string(output)), nil 47 | } 48 | -------------------------------------------------------------------------------- /arch_test.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "testing" 7 | 8 | "github.com/oalders/is/ops" 9 | "github.com/oalders/is/types" 10 | "github.com/stretchr/testify/assert" 11 | ) 12 | 13 | func TestArchCmd(t *testing.T) { 14 | t.Parallel() 15 | type ArchTest struct { 16 | Cmd ArchCmd 17 | Error bool 18 | Success bool 19 | } 20 | 21 | tests := []ArchTest{ 22 | {ArchCmd{ops.Eq, "zzz"}, false, false}, 23 | {ArchCmd{ops.Ne, "zzz"}, false, true}, 24 | {ArchCmd{ops.In, "amd64,arm,arm64"}, false, true}, 25 | {ArchCmd{ops.In, "X"}, false, false}, 26 | {ArchCmd{ops.Like, "zzz"}, false, false}, 27 | {ArchCmd{ops.Unlike, "zzz"}, false, true}, 28 | } 29 | 30 | for _, test := range tests { 31 | ctx := types.Context{ 32 | Context: context.Background(), 33 | } 34 | err := test.Cmd.Run(&ctx) 35 | name := fmt.Sprintf("%s %s", test.Cmd.Op, test.Cmd.Val) 36 | if test.Error { 37 | assert.Error(t, err, name) 38 | } else { 39 | assert.NoError(t, err, name) 40 | } 41 | if test.Success { 42 | assert.True(t, ctx.Success, name) 43 | } else { 44 | assert.False(t, ctx.Success, name) 45 | } 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /reader/reader.go: -------------------------------------------------------------------------------- 1 | // package reader contains ini file reader logic 2 | package reader 3 | 4 | import ( 5 | "errors" 6 | "fmt" 7 | "log" 8 | "os" 9 | 10 | "github.com/oalders/is/types" 11 | "gopkg.in/ini.v1" 12 | ) 13 | 14 | func MaybeReadINI(ctx *types.Context, path string) (*types.OSRelease, error) { 15 | if ctx.Debug { 16 | log.Println("Trying to parse " + path) 17 | } 18 | _, err := os.Stat(path) 19 | if err != nil { 20 | if errors.Is(err, os.ErrNotExist) { 21 | return nil, nil //nolint:nilnil 22 | } 23 | return nil, fmt.Errorf("could not stat file: %w", err) 24 | } 25 | 26 | cfg, err := ini.Load(path) 27 | if err != nil { 28 | return nil, fmt.Errorf("could not load file: %w", err) 29 | } 30 | 31 | section := cfg.Section("") 32 | release := types.OSRelease{ 33 | ID: section.Key("ID").String(), 34 | IDLike: section.Key("ID_LIKE").String(), 35 | Name: section.Key("NAME").String(), 36 | PrettyName: section.Key("PRETTY_NAME").String(), 37 | VersionCodeName: section.Key("VERSION_CODENAME").String(), 38 | Version: section.Key("VERSION_ID").String(), 39 | } 40 | return &release, nil 41 | } 42 | -------------------------------------------------------------------------------- /precious.toml: -------------------------------------------------------------------------------- 1 | [commands.gofumpt] 2 | type = "tidy" 3 | include = ["**/*.go",] 4 | cmd = [ 5 | "gofumpt", 6 | "-w", 7 | ] 8 | ok_exit_codes = 0 9 | 10 | [commands.goimports] 11 | type = "tidy" 12 | include = ["**/*.go",] 13 | cmd = [ 14 | "goimports", 15 | "-w", 16 | ] 17 | ok_exit_codes = 0 18 | 19 | [commands.golangci-lint] 20 | type = "lint" 21 | invoke = "per-dir" 22 | working_dir = "root" 23 | path_args = "dir" 24 | include = ["**/*.go",] 25 | cmd = [ 26 | "golangci-lint", 27 | "run", 28 | "-c", 29 | "$PRECIOUS_ROOT/.golangci.yaml", 30 | "--allow-parallel-runners", 31 | ] 32 | ok_exit_codes = 0 33 | 34 | [commands.omegasort-gitignore] 35 | type = "both" 36 | include = "**/.gitignore" 37 | cmd = [ "omegasort", "--sort", "path", "--unique" ] 38 | lint_flags = "--check" 39 | tidy_flags = "--in-place" 40 | ok_exit_codes = 0 41 | lint_failure_exit_codes = 1 42 | ignore_stderr = [ 43 | "The .+ file is not sorted", 44 | "The .+ file is not unique", 45 | ] 46 | 47 | # [commands.ppath] 48 | # type = "lint" 49 | # exclude = [ 50 | # "this-does-not-exist.txt", 51 | # "nope*", 52 | # ] 53 | # include = ["precious.toml"] 54 | # run_mode = "files" 55 | # cmd = ["ppath"] 56 | # ok_exit_codes = 0 57 | -------------------------------------------------------------------------------- /audio/audio_unsupported.go: -------------------------------------------------------------------------------- 1 | //go:build freebsd || netbsd 2 | 3 | // Package audio provides functions to read system audio volume information. 4 | package audio 5 | 6 | import ( 7 | "errors" 8 | 9 | "github.com/oalders/is/types" 10 | ) 11 | 12 | // VolumeInfo represents audio volume information. 13 | type VolumeInfo struct { 14 | Level int // Volume level (0-100) 15 | Muted bool // Whether audio is muted 16 | } 17 | 18 | // Summary retrieves the current system volume information. 19 | // On FreeBSD and NetBSD, this returns an error as audio functionality is not supported. 20 | func Summary(ctx *types.Context) (*VolumeInfo, error) { 21 | return nil, errors.New("audio functionality not supported on this platform") 22 | } 23 | 24 | // Level returns just the volume level (0-100). 25 | // On FreeBSD and NetBSD, this returns an error as audio functionality is not supported. 26 | func Level() (int, error) { 27 | return 0, errors.New("audio functionality not supported on this platform") 28 | } 29 | 30 | // IsMuted returns whether the system audio is currently muted. 31 | // On FreeBSD and NetBSD, this returns an error as audio functionality is not supported. 32 | func IsMuted() (bool, error) { 33 | return false, errors.New("audio functionality not supported on this platform") 34 | } 35 | -------------------------------------------------------------------------------- /audio/audio.go: -------------------------------------------------------------------------------- 1 | //go:build !freebsd && !netbsd 2 | 3 | // Package audio provides functions to read system audio volume information. 4 | package audio 5 | 6 | import ( 7 | "fmt" 8 | 9 | "github.com/itchyny/volume-go" 10 | "github.com/oalders/is/types" 11 | ) 12 | 13 | // VolumeInfo represents audio volume information. 14 | type VolumeInfo struct { 15 | Level int // Volume level (0-100) 16 | Muted bool // Whether audio is muted 17 | } 18 | 19 | // Summary retrieves the current system volume information. 20 | func Summary(ctx *types.Context) (*VolumeInfo, error) { 21 | vol, err := Level() 22 | if err != nil { 23 | return nil, err 24 | } 25 | 26 | muted, err := IsMuted() 27 | if err != nil { 28 | return nil, err 29 | } 30 | ctx.Success = true 31 | 32 | return &VolumeInfo{ 33 | Level: vol, 34 | Muted: muted, 35 | }, nil 36 | } 37 | 38 | // Level returns just the volume level (0-100). 39 | func Level() (int, error) { 40 | level, err := volume.GetVolume() 41 | if err != nil { 42 | return 0, fmt.Errorf("get level: %w", err) 43 | } 44 | return level, nil 45 | } 46 | 47 | // IsMuted returns whether the system audio is currently muted. 48 | func IsMuted() (bool, error) { 49 | isMuted, err := volume.GetMuted() 50 | if err != nil { 51 | return false, fmt.Errorf("get mute status: %w", err) 52 | } 53 | return isMuted, nil 54 | } 55 | -------------------------------------------------------------------------------- /fso_test.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "context" 5 | "testing" 6 | 7 | "github.com/oalders/is/ops" 8 | "github.com/oalders/is/types" 9 | "github.com/stretchr/testify/assert" 10 | ) 11 | 12 | func TestFSOLastModifiedTime(t *testing.T) { 13 | t.Parallel() 14 | const tmux = "testdata/bin/tmux" 15 | { 16 | ctx := &types.Context{ 17 | Context: context.Background(), 18 | Debug: true, 19 | } 20 | cmd := FSOCmd{Age: AgeCmp{tmux, ops.Gt, "1", "s"}} 21 | err := cmd.Run(ctx) 22 | assert.NoError(t, err) 23 | assert.True(t, ctx.Success) 24 | } 25 | { 26 | ctx := &types.Context{ 27 | Context: context.Background(), 28 | Debug: true, 29 | } 30 | cmd := FSOCmd{Age: AgeCmp{tmux, ops.Lt, "100000", "days"}} 31 | err := cmd.Run(ctx) 32 | assert.NoError(t, err) 33 | assert.True(t, ctx.Success) 34 | } 35 | { 36 | ctx := &types.Context{ 37 | Context: context.Background(), 38 | Debug: true, 39 | } 40 | cmd := FSOCmd{Age: AgeCmp{tmux, ops.Lt, "1.1", "d"}} 41 | err := cmd.Run(ctx) 42 | assert.Error(t, err) 43 | assert.False(t, ctx.Success) 44 | } 45 | { 46 | ctx := &types.Context{ 47 | Context: context.Background(), 48 | Debug: true, 49 | } 50 | cmd := FSOCmd{Age: AgeCmp{"tmuxxx", ops.Lt, "1", "d"}} 51 | err := cmd.Run(ctx) 52 | assert.Error(t, err) 53 | assert.False(t, ctx.Success) 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /test/known/no-battery.bats: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bats 2 | 3 | bats_require_minimum_version 1.5.0 4 | 5 | setup() { 6 | if ./is battery count gt 0; then 7 | skip "Skipping the battery tests if a battery has been found" 8 | fi 9 | } 10 | 11 | @test "is known battery" { 12 | run ./is known battery 13 | [ "${status}" -eq 1 ] 14 | [[ "${output}" == *"Usage: is known battery "* ]] 15 | } 16 | 17 | @test "is known battery charge-rate --nth 0" { 18 | run ./is known battery charge-rate --nth 0 19 | [ "${status}" -eq 1 ] 20 | [[ "${output}" == *"use --nth 1 to get the first battery"* ]] 21 | } 22 | 23 | @test "is known battery count" { 24 | ./is known battery count 25 | } 26 | 27 | @test "is known battery charge-rate --round" { 28 | ./is known battery charge-rate --round 29 | } 30 | 31 | @test "is known battery current-capacity" { 32 | ./is known battery current-capacity 33 | } 34 | 35 | @test "is known battery current-charge" { 36 | ./is known battery current-charge 37 | } 38 | 39 | @test "is known battery design-capacity" { 40 | ./is known battery design-capacity 41 | } 42 | 43 | @test "is known battery design-voltage" { 44 | ./is known battery design-voltage 45 | } 46 | 47 | @test "is known battery last-full-capacity" { 48 | ./is known battery last-full-capacity 49 | } 50 | 51 | @test "is known battery voltage" { 52 | ./is known battery voltage 53 | } 54 | -------------------------------------------------------------------------------- /mac/mac.go: -------------------------------------------------------------------------------- 1 | // Package mac contains macOS logic 2 | package mac 3 | 4 | import ( 5 | "fmt" 6 | "os/exec" 7 | "strings" 8 | 9 | "github.com/oalders/is/types" 10 | "github.com/oalders/is/version" 11 | ) 12 | 13 | // CodeName returns the human readable name of a macOS release. 14 | func CodeName(osVersion string) string { 15 | got, err := version.NewVersion(osVersion) 16 | if err != nil { 17 | return "" 18 | } 19 | 20 | // https://en.wikipedia.org/wiki/List_of_Apple_codenames 21 | major := map[int]string{ 22 | 15: "sequoia", 23 | 14: "sonoma", 24 | 13: "ventura", 25 | 12: "monterey", 26 | 11: "big sur", 27 | } 28 | 29 | segments := got.Segments() 30 | 31 | if v, ok := major[segments[0]]; ok { 32 | return v 33 | } 34 | 35 | if segments[0] != 10 { 36 | return "" 37 | } 38 | 39 | minor := map[int]string{ 40 | 15: "catalina", 41 | 14: "mojave", 42 | 13: "high sierra", 43 | 12: "sierra", 44 | 11: "el capitan", 45 | 10: "yosemite", 46 | 9: "mavericks", 47 | 8: "mountain lion", // released 2012 48 | } 49 | 50 | if v, ok := minor[segments[1]]; ok { 51 | return v 52 | } 53 | 54 | return "" 55 | } 56 | 57 | func Version(ctx *types.Context) (string, error) { 58 | o, err := exec.CommandContext(ctx.Context, "sw_vers", "-productVersion").Output() 59 | if err != nil { 60 | return "", fmt.Errorf("could not run sw_vers -productVersion: %w", err) 61 | } 62 | return strings.TrimRight(string(o), "\n"), nil 63 | } 64 | -------------------------------------------------------------------------------- /var.go: -------------------------------------------------------------------------------- 1 | // This file handles environment variable parsing 2 | package main 3 | 4 | import ( 5 | "fmt" 6 | "os" 7 | "strconv" 8 | 9 | "github.com/oalders/is/types" 10 | ) 11 | 12 | //nolint:cyclop 13 | func (r *VarCmd) Run(ctx *types.Context) error { 14 | ctx.Success = false 15 | 16 | val, set := os.LookupEnv(r.Name) 17 | switch r.Op { 18 | case "set": 19 | ctx.Success = set 20 | return nil 21 | case "unset": 22 | ctx.Success = !set 23 | return nil 24 | case "true": 25 | if !set { 26 | return fmt.Errorf("environment variable %s is not set", r.Name) 27 | } 28 | boolVal, err := strconv.ParseBool(val) 29 | if err != nil { 30 | return fmt.Errorf( 31 | "environment variable %s value %q cannot be parsed as boolean: %w", 32 | r.Name, 33 | val, 34 | err, 35 | ) 36 | } 37 | ctx.Success = boolVal 38 | return nil 39 | case "false": 40 | if !set { 41 | return fmt.Errorf("environment variable %s is not set", r.Name) 42 | } 43 | boolVal, err := strconv.ParseBool(val) 44 | if err != nil { 45 | return fmt.Errorf( 46 | "environment variable %s value %q cannot be parsed as boolean: %w", 47 | r.Name, 48 | val, 49 | err, 50 | ) 51 | } 52 | ctx.Success = !boolVal 53 | return nil 54 | default: 55 | if !set { 56 | return fmt.Errorf("environment variable %s is not set", r.Name) 57 | } 58 | success, err := compareOutput(ctx, r.Compare, r.Op, val, r.Val) 59 | ctx.Success = success 60 | return err 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /battery.go: -------------------------------------------------------------------------------- 1 | // This file handles battery info checks 2 | package main 3 | 4 | import ( 5 | "errors" 6 | "strconv" 7 | 8 | "github.com/oalders/is/battery" 9 | "github.com/oalders/is/compare" 10 | "github.com/oalders/is/types" 11 | ) 12 | 13 | // Run "is battery ...". 14 | // 15 | //nolint:cyclop 16 | func (r *BatteryCmd) Run(ctx *types.Context) error { 17 | attr, err := battery.GetAttr(ctx, r.Attr, r.Nth) 18 | ctx.Success = false 19 | 20 | if err != nil { 21 | return err 22 | } 23 | 24 | switch got := attr.(type) { 25 | case float64: 26 | want, err := strconv.ParseFloat(r.Val, 64) 27 | if err != nil { 28 | return errors.Join( 29 | errors.New("wanted result could not be converted to a float"), 30 | err, 31 | ) 32 | } 33 | ok, err := compare.IntegersOrFloats(ctx, r.Op, got, want) 34 | if err != nil { 35 | return err 36 | } 37 | ctx.Success = ok 38 | case int: 39 | want, err := strconv.ParseInt(r.Val, 0, 32) 40 | if err != nil { 41 | return errors.Join( 42 | errors.New("wanted result could not be converted to an integer"), 43 | err, 44 | ) 45 | } 46 | ok, err := compare.IntegersOrFloats(ctx, r.Op, got, int(want)) 47 | if err != nil { 48 | return err 49 | } 50 | ctx.Success = ok 51 | case string: 52 | ok, err := compare.Strings(ctx, r.Op, got, r.Val) 53 | if err != nil { 54 | return err 55 | } 56 | ctx.Success = ok 57 | default: 58 | return errors.New("unexpected type for " + r.Val) 59 | } 60 | return nil 61 | } 62 | -------------------------------------------------------------------------------- /test/battery.bats: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bats 2 | 3 | bats_require_minimum_version 1.5.0 4 | 5 | setup() { 6 | if ./is battery count eq 0; then 7 | skip "Skipping battery tests as no battery found" 8 | fi 9 | } 10 | 11 | @test "is battery" { 12 | run ./is battery 13 | [ "${status}" -eq 1 ] 14 | [[ "${output}" == *"Usage: is battery "* ]] 15 | } 16 | 17 | @test "is battery count gt 0" { 18 | ./is battery count gt 0 19 | } 20 | 21 | @test "is battery charge-rate gt 0 --round" { 22 | ./is battery charge-rate gt 0 --round 23 | } 24 | 25 | @test "is battery charge-rate gt 0" { 26 | ./is battery charge-rate gt 0 27 | } 28 | 29 | @test "is battery current-capacity gt 0" { 30 | ./is battery current-capacity gt 0 31 | } 32 | 33 | @test "is battery current-charge gt 0" { 34 | ./is battery current-charge gt 0 35 | } 36 | 37 | @test "is battery design-capacity gt 0" { 38 | ./is battery design-capacity gt 0 39 | } 40 | 41 | @test "is battery design-voltage gt 0" { 42 | ./is battery design-voltage gt 0 43 | } 44 | 45 | @test "is battery last-full-capacity gt 0" { 46 | ./is battery last-full-capacity gt 0 47 | } 48 | 49 | @test "is battery state like charg" { 50 | ./is battery state like char 51 | } 52 | 53 | @test "is battery voltage gt 0 --debug" { 54 | ./is battery voltage gt 0 --debug 55 | } 56 | 57 | @test "is battery --nth 77 voltage gt 0" { 58 | run ./is battery --nth 77 voltage gt 0 59 | [[ $status -ne 0 ]] 60 | [[ "$output" == *"battery 77 requested, but only"* ]] 61 | } 62 | -------------------------------------------------------------------------------- /test/no-battery.bats: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bats 2 | 3 | bats_require_minimum_version 1.5.0 4 | 5 | setup() { 6 | if ./is battery count gt 0; then 7 | skip "Skipping these battery if battery present" 8 | fi 9 | } 10 | 11 | @test "is battery" { 12 | run ./is battery 13 | [ "${status}" -eq 1 ] 14 | [[ "${output}" == *"Usage: is battery "* ]] 15 | } 16 | 17 | @test "is battery count eq 0" { 18 | ./is battery count eq 0 19 | } 20 | 21 | @test "is battery charge-rate eq 0 --round" { 22 | ./is battery charge-rate eq 0 --round 23 | } 24 | 25 | @test "is battery charge-rate eq 0" { 26 | ./is battery charge-rate eq 0 27 | } 28 | 29 | @test "is battery current-capacity eq 0" { 30 | ./is battery current-capacity eq 0 31 | } 32 | 33 | @test "is battery current-charge eq 0" { 34 | ./is battery current-charge eq 0 35 | } 36 | 37 | @test "is battery design-capacity eq 0" { 38 | ./is battery design-capacity eq 0 39 | } 40 | 41 | @test "is battery design-voltage eq 0" { 42 | ./is battery design-voltage eq 0 43 | } 44 | 45 | @test "is battery last-full-capacity eq 0" { 46 | ./is battery last-full-capacity eq 0 47 | } 48 | 49 | @test "is battery state unlike charg" { 50 | ./is battery state unlike char 51 | } 52 | 53 | @test "is battery voltage eq 0 --debug" { 54 | ./is battery voltage eq 0 --debug 55 | } 56 | 57 | # fixme 58 | # @test "is battery --nth 77 voltage eq 0" { 59 | # run ./is battery --nth 77 voltage eq 0 60 | # [[ $status -ne 0 ]] 61 | # [[ "$output" == *"battery 77 requested, but only"* ]] 62 | # } 63 | -------------------------------------------------------------------------------- /test/known/battery.bats: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bats 2 | 3 | bats_require_minimum_version 1.5.0 4 | 5 | setup() { 6 | if ./is battery count eq 0; then 7 | skip "Skipping battery tests as no battery found" 8 | fi 9 | } 10 | 11 | @test "is known battery" { 12 | run ./is known battery 13 | [ "${status}" -eq 1 ] 14 | [[ "${output}" == *"Usage: is known battery "* ]] 15 | } 16 | 17 | @test "is known battery charge-rate --nth 0" { 18 | run ./is known battery charge-rate --nth 0 19 | [ "${status}" -eq 1 ] 20 | [[ "${output}" == *"use --nth 1 to get the first battery"* ]] 21 | } 22 | 23 | @test "is known battery count" { 24 | ./is known battery count 25 | } 26 | 27 | @test "is known battery charge-rate --round" { 28 | ./is known battery charge-rate --round 29 | } 30 | 31 | @test "is known battery current-capacity" { 32 | ./is known battery current-capacity 33 | } 34 | 35 | @test "is known battery current-charge" { 36 | ./is known battery current-charge 37 | } 38 | 39 | @test "is known battery design-capacity" { 40 | ./is known battery design-capacity 41 | } 42 | 43 | @test "is known battery design-voltage" { 44 | ./is known battery design-voltage 45 | } 46 | 47 | @test "is known battery last-full-capacity" { 48 | ./is known battery last-full-capacity 49 | } 50 | 51 | @test "is known battery state" { 52 | ./is battery count gt 0 && ./is known battery state 53 | } 54 | 55 | @test "is known battery voltage" { 56 | ./is known battery voltage 57 | } 58 | 59 | @test "is known summary battery" { 60 | ./is known summary battery 61 | } 62 | 63 | @test "is known summary battery --json" { 64 | ./is known summary battery --json 65 | } 66 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/oalders/is 2 | 3 | go 1.23.0 4 | 5 | require ( 6 | github.com/alecthomas/kong v0.7.1 7 | github.com/charmbracelet/lipgloss v1.1.0 8 | github.com/distatus/battery v0.11.0 9 | github.com/hashicorp/go-version v1.6.0 10 | github.com/itchyny/volume-go v0.2.2 11 | github.com/posener/complete v1.2.3 12 | github.com/stretchr/testify v1.8.3 13 | github.com/willabides/kongplete v0.3.0 14 | gopkg.in/ini.v1 v1.67.0 15 | ) 16 | 17 | require ( 18 | github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect 19 | github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc // indirect 20 | github.com/charmbracelet/x/ansi v0.8.0 // indirect 21 | github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd // indirect 22 | github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91 // indirect 23 | github.com/charmbracelet/x/term v0.2.1 // indirect 24 | github.com/davecgh/go-spew v1.1.1 // indirect 25 | github.com/go-ole/go-ole v1.2.6 // indirect 26 | github.com/hashicorp/errwrap v1.0.0 // indirect 27 | github.com/hashicorp/go-multierror v1.0.0 // indirect 28 | github.com/lucasb-eyer/go-colorful v1.2.0 // indirect 29 | github.com/mattn/go-isatty v0.0.20 // indirect 30 | github.com/mattn/go-runewidth v0.0.16 // indirect 31 | github.com/moutend/go-wca v0.2.0 // indirect 32 | github.com/muesli/termenv v0.16.0 // indirect 33 | github.com/pkg/errors v0.9.1 // indirect 34 | github.com/pmezard/go-difflib v1.0.0 // indirect 35 | github.com/rivo/uniseg v0.4.7 // indirect 36 | github.com/riywo/loginshell v0.0.0-20200815045211-7d26008be1ab // indirect 37 | github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect 38 | golang.org/x/sys v0.30.0 // indirect 39 | gopkg.in/yaml.v3 v3.0.1 // indirect 40 | howett.net/plist v1.0.1 // indirect 41 | ) 42 | -------------------------------------------------------------------------------- /os.go: -------------------------------------------------------------------------------- 1 | // This file handles OS info parsing 2 | package main 3 | 4 | import ( 5 | "errors" 6 | 7 | "github.com/oalders/is/compare" 8 | "github.com/oalders/is/ops" 9 | "github.com/oalders/is/os" 10 | "github.com/oalders/is/types" 11 | ) 12 | 13 | // Run "is os ...". 14 | // 15 | //nolint:funlen 16 | func (r *OSCmd) Run(ctx *types.Context) error { //nolint:cyclop 17 | attr, err := os.Info(ctx, r.Attr) 18 | ctx.Success = false // os.Info set success to true 19 | 20 | if err != nil { 21 | return err 22 | } 23 | 24 | switch r.Attr { 25 | case "version": 26 | switch { 27 | case r.Major: 28 | success, err := compare.VersionSegment(ctx, r.Op, attr, r.Val, 0) 29 | ctx.Success = success 30 | return err 31 | case r.Minor: 32 | success, err := compare.VersionSegment(ctx, r.Op, attr, r.Val, 1) 33 | ctx.Success = success 34 | return err 35 | case r.Patch: 36 | success, err := compare.VersionSegment(ctx, r.Op, attr, r.Val, 2) 37 | ctx.Success = success 38 | return err 39 | } 40 | 41 | if r.Op == ops.Like || r.Op == ops.Unlike { 42 | success, err := compare.Strings(ctx, r.Op, attr, r.Val) 43 | ctx.Success = success 44 | return err 45 | } 46 | 47 | success, err := compare.Versions(ctx, r.Op, attr, r.Val) 48 | ctx.Success = success 49 | if err != nil { 50 | return err 51 | } 52 | default: 53 | switch { 54 | case r.Major: 55 | return errors.New("--major can only be used with version") 56 | case r.Minor: 57 | return errors.New("--minor can only be used with version") 58 | case r.Patch: 59 | return errors.New("--patch can only be used with version") 60 | } 61 | 62 | switch r.Op { 63 | case ops.Eq, ops.In, ops.Ne, ops.Like, ops.Unlike: 64 | success, err := compare.Strings(ctx, r.Op, attr, r.Val) 65 | ctx.Success = success 66 | return err 67 | } 68 | } 69 | 70 | return nil 71 | } 72 | -------------------------------------------------------------------------------- /test/known.bats: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bats 2 | 3 | semver=./testdata/bin/semver 4 | tmux=./testdata/bin/tmux 5 | 6 | bats_require_minimum_version 1.5.0 7 | 8 | @test "is known cli" { 9 | ./is known cli version $tmux 10 | } 11 | 12 | @test "is known os" { 13 | ./is known os name 14 | } 15 | 16 | @test "is known arch" { 17 | ./is known arch 18 | } 19 | 20 | @test "ensure something is printed" { 21 | run -0 ./is known arch 22 | [ -n "${lines[0]}" ] 23 | } 24 | 25 | @test "is known cli version semver" { 26 | run -0 ./is known cli version $semver 27 | [ "${lines[0]}" = "1.2.3" ] 28 | } 29 | 30 | @test "is known cli version --major semver" { 31 | run -0 ./is known cli version --major $semver 32 | [ "${lines[0]}" = "1" ] 33 | } 34 | 35 | @test "is known cli version --minor semver" { 36 | run -0 ./is known cli version --minor $semver 37 | [ "${lines[0]}" = "2" ] 38 | } 39 | 40 | @test "is known cli version --patch semver" { 41 | run -0 ./is known cli version --patch $semver 42 | [ "${lines[0]}" = "3" ] 43 | } 44 | 45 | @test "is known cli version --major --minor semver" { 46 | run ! ./is known cli version --major --minor $semver 47 | } 48 | 49 | @test "is known os version --major" { 50 | ./is known os version --major 51 | } 52 | 53 | @test "! is known os name --minor" { 54 | run ! ./is known os name --minor 55 | } 56 | 57 | @test "! is known os name --patch" { 58 | run ! ./is known os name --patch 59 | } 60 | 61 | @test "! is known os version --major" { 62 | run ! ./is known os name --major 63 | } 64 | 65 | @test "is known os version --minor" { 66 | ./is known os version --minor 67 | } 68 | 69 | @test "is known os version --patch" { 70 | ./is known os version --patch 71 | } 72 | 73 | @test "is known summary os" { 74 | ./is known summary os 75 | } 76 | 77 | @test "is known var PATH" { 78 | ./is known var PATH 79 | } 80 | 81 | @test "is known summary var" { 82 | ./is known summary var 83 | } 84 | -------------------------------------------------------------------------------- /.golangci.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | version: "2" 3 | linters: 4 | enable: 5 | - asasalint 6 | - asciicheck 7 | - bidichk 8 | - bodyclose 9 | - containedctx 10 | - contextcheck 11 | - copyloopvar 12 | - cyclop 13 | - decorder 14 | - dogsled 15 | - dupl 16 | - dupword 17 | - durationcheck 18 | - errchkjson 19 | - errname 20 | - errorlint 21 | - exhaustive 22 | - forbidigo 23 | - forcetypeassert 24 | - funlen 25 | - gochecknoglobals 26 | - gochecknoinits 27 | - gocognit 28 | - goconst 29 | - gocritic 30 | - gocyclo 31 | - godot 32 | - godox 33 | - goheader 34 | - gomoddirectives 35 | - gomodguard 36 | - goprintffuncname 37 | - gosec 38 | - grouper 39 | - importas 40 | - interfacebloat 41 | - ireturn 42 | - lll 43 | - loggercheck 44 | - maintidx 45 | - makezero 46 | - misspell 47 | - nakedret 48 | - nestif 49 | - nilerr 50 | - nilnil 51 | - noctx 52 | - nolintlint 53 | - nonamedreturns 54 | - nosprintfhostport 55 | - paralleltest 56 | - prealloc 57 | - predeclared 58 | - promlinter 59 | - reassign 60 | - revive 61 | - rowserrcheck 62 | - sqlclosecheck 63 | - staticcheck 64 | - tagliatelle 65 | - testableexamples 66 | - testpackage 67 | - thelper 68 | - tparallel 69 | - unconvert 70 | - unparam 71 | - usestdlibvars 72 | - varnamelen 73 | - wastedassign 74 | - whitespace 75 | - wrapcheck 76 | disable: 77 | - nlreturn 78 | - wsl 79 | settings: 80 | funlen: 81 | lines: 75 82 | statements: 40 83 | # govet: 84 | # enable: 85 | # - fieldalignment 86 | lll: 87 | line-length: 100 88 | wrapcheck: 89 | ignore-package-globs: 90 | - github.com/oalders/is/* 91 | exclusions: 92 | generated: lax 93 | presets: 94 | - comments 95 | - common-false-positives 96 | - legacy 97 | - std-error-handling 98 | rules: 99 | - linters: 100 | - funlen 101 | source: ^func Test 102 | paths: 103 | - third_party$ 104 | - builtin$ 105 | - examples$ 106 | formatters: 107 | enable: 108 | - gci 109 | - gofmt 110 | - gofumpt 111 | - goimports 112 | exclusions: 113 | generated: lax 114 | paths: 115 | - third_party$ 116 | - builtin$ 117 | - examples$ 118 | -------------------------------------------------------------------------------- /os/os.go: -------------------------------------------------------------------------------- 1 | // Package os handles OS info parsing 2 | package os 3 | 4 | import ( 5 | "errors" 6 | "log" 7 | "runtime" 8 | 9 | "github.com/oalders/is/attr" 10 | "github.com/oalders/is/mac" 11 | "github.com/oalders/is/reader" 12 | "github.com/oalders/is/types" 13 | ) 14 | 15 | const ( 16 | darwin = "darwin" 17 | linux = "linux" 18 | osReleaseFile = "/etc/os-release" 19 | ) 20 | 21 | func Info(ctx *types.Context, argName string) (string, error) { 22 | maybeDebug(ctx) 23 | if argName == attr.Name { 24 | return runtime.GOOS, nil 25 | } 26 | 27 | if runtime.GOOS == linux { 28 | return linuxOS(ctx, argName) 29 | } 30 | 31 | if runtime.GOOS != darwin { 32 | return "", nil 33 | } 34 | 35 | macVersion, err := mac.Version(ctx) 36 | if err != nil { 37 | return "", err 38 | } 39 | 40 | switch argName { 41 | case attr.Version: 42 | return macVersion, nil 43 | case attr.VersionCodename: 44 | return mac.CodeName(macVersion), nil 45 | } 46 | 47 | return "", nil 48 | } 49 | 50 | func ReleaseSummary(ctx *types.Context) (*types.OSRelease, error) { 51 | release, err := reader.MaybeReadINI(ctx, osReleaseFile) 52 | if err != nil { 53 | return nil, err 54 | } 55 | if release == nil { 56 | release = &types.OSRelease{} 57 | } 58 | release.Name = runtime.GOOS 59 | 60 | if runtime.GOOS == darwin { 61 | v, versionErr := mac.Version(ctx) 62 | if versionErr != nil { 63 | return nil, versionErr 64 | } 65 | release.Version = v 66 | release.VersionCodeName = mac.CodeName(release.Version) 67 | } 68 | return release, nil 69 | } 70 | 71 | func linuxOS(ctx *types.Context, argName string) (string, error) { 72 | release, err := reader.MaybeReadINI(ctx, osReleaseFile) 73 | if err != nil { 74 | return "", err 75 | } 76 | if release == nil { 77 | return "", errors.New("release info cannot be found") 78 | } 79 | 80 | result := "" 81 | switch argName { 82 | case "id": 83 | result = release.ID 84 | case "id-like": 85 | result = release.IDLike 86 | case "pretty-name": 87 | result = release.PrettyName 88 | case attr.Version: 89 | result = release.Version 90 | case attr.VersionCodename: 91 | result = release.VersionCodeName 92 | } 93 | if result != "" { 94 | return result, nil 95 | } 96 | return "", nil 97 | } 98 | 99 | func maybeDebug(ctx *types.Context) { 100 | if ctx.Debug { 101 | log.Printf( 102 | "Run %q to see available os data\n", 103 | "is known summary os", 104 | ) 105 | } 106 | } 107 | -------------------------------------------------------------------------------- /main.go: -------------------------------------------------------------------------------- 1 | // The main package is the command line runner for the is command. 2 | package main 3 | 4 | import ( 5 | "context" 6 | "os" 7 | "time" 8 | 9 | "github.com/alecthomas/kong" 10 | "github.com/oalders/is/types" 11 | "github.com/posener/complete" 12 | "github.com/willabides/kongplete" 13 | ) 14 | 15 | func main() { 16 | //nolint:lll,govet,nolintlint 17 | var API struct { 18 | Arch ArchCmd `cmd:"" help:"Check arch e.g. \"is arch like x64\""` 19 | Audio AudioCmd `cmd:"" help:"Check audio attributes. e.g. \"is audio level gt 50\""` 20 | Battery BatteryCmd `cmd:"" help:"Check battery attributes. e.g. \"is battery state eq charging\""` 21 | CLI CLICmd `cmd:"" help:"Check cli version. e.g. \"is cli version tmux gte 3\""` 22 | Debug bool `help:"turn on debugging statements"` 23 | FSO FSOCmd `cmd:"" help:"Check fso (file system object). e.g. \"is fso age gte 3 days\""` 24 | Known KnownCmd `cmd:""` 25 | OS OSCmd `cmd:"" help:"Check OS attributes. e.g. \"is os name eq darwin\""` 26 | There ThereCmd `cmd:"" help:"Check if command exists. e.g. \"is there git\""` 27 | User UserCmd `cmd:"" help:"Info about current user. e.g. \"is user sudoer\""` 28 | Var VarCmd `cmd:"" help:"Check environment variables. e.g. \"is var EDITOR eq nvim\""` 29 | Version kong.VersionFlag `help:"Print version to screen"` 30 | 31 | InstallCompletions kongplete.InstallCompletions `cmd:"" help:"install shell completions. e.g. \"is install-completions\" and then run the command which is printed to your terminal to get completion in your current session. add the command to a .bashrc or similar to get completion across all sessions."` //nolint:lll 32 | } 33 | 34 | parser := kong.Must(&API, 35 | kong.Name("is"), 36 | kong.Description("an inspector for your environment"), 37 | kong.UsageOnError(), 38 | kong.Vars{"version": "0.11.0"}, 39 | ) 40 | 41 | // Run kongplete.Complete to handle completion requests 42 | kongplete.Complete(parser, 43 | kongplete.WithPredictor("file", complete.PredictFiles("*")), 44 | ) 45 | 46 | runCtx, err := parser.Parse(os.Args[1:]) 47 | parser.FatalIfErrorf(err) 48 | 49 | ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) 50 | 51 | runContext := types.Context{Context: ctx, Debug: API.Debug} 52 | err = runCtx.Run(&runContext) 53 | runCtx.FatalIfErrorf(err) 54 | 55 | exitCode := 1 56 | if runContext.Success { 57 | exitCode = 0 58 | } 59 | cancel() // Cancel context before exiting 60 | os.Exit(exitCode) 61 | } 62 | -------------------------------------------------------------------------------- /.goreleaser.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | version: 2 3 | 4 | # This is an example .goreleaser.yml file with some sensible defaults. 5 | # Make sure to check the documentation at https://goreleaser.com 6 | 7 | # The lines below are called `modelines`. See `:help modeline` 8 | # Feel free to remove those if you don't want/need to use them. 9 | # yaml-language-server: $schema=https://goreleaser.com/static/schema.json 10 | # vim: set ts=2 sw=2 tw=0 fo=cnqoj 11 | 12 | before: 13 | hooks: 14 | # You may remove this if you don't use go modules. 15 | - go mod tidy 16 | # you may remove this if you don't need go generate 17 | # - go generate ./... 18 | 19 | builds: 20 | - env: 21 | - CGO_ENABLED=0 22 | goarch: 23 | - amd64 24 | - arm 25 | - arm64 26 | goarm: 27 | - "7" 28 | ignore: 29 | - goos: darwin 30 | goarch: arm 31 | - goos: netbsd 32 | goarch: arm 33 | - goos: freebsd 34 | goarch: arm 35 | - goos: windows 36 | goos: 37 | - linux 38 | - windows 39 | - darwin 40 | - netbsd 41 | - freebsd 42 | 43 | archives: 44 | - # this name template makes the OS and Arch compatible with the results of `uname`. 45 | name_template: >- 46 | {{ .ProjectName }}_ 47 | {{- title .Os }}_ 48 | {{- if eq .Arch "amd64" }}x86_64 49 | {{- else if eq .Arch "386" }}i386 50 | {{- else }}{{ .Arch }}{{ end }} 51 | {{- if .Arm }}v{{ .Arm }}{{ end }} 52 | # use zip for windows archives, tar.gz for others 53 | format_overrides: 54 | - goos: windows 55 | formats: [zip] 56 | - goos: linux 57 | formats: [tar.gz] 58 | - goos: darwin 59 | formats: [tar.gz] 60 | - goos: netbsd 61 | formats: [tar.gz] 62 | - goos: freebsd 63 | formats: [tar.gz] 64 | 65 | nfpms: 66 | - 67 | id: 'is' 68 | package_name: is 69 | ids: 70 | - 'is' 71 | vendor: 'Olaf Alders' 72 | homepage: 'https://www.olafalders.com/' 73 | maintainer: 'Olaf Alders ' 74 | description: 'is tells you what is available in your environment' 75 | license: 'Apache 2.0 or MIT' 76 | provides: 77 | - is 78 | formats: 79 | - 'deb' 80 | - 'rpm' 81 | bindir: '/usr/bin' 82 | contents: 83 | - src: 'CHANGELOG.md' 84 | dst: '/usr/share/doc/is/CHANGELOG.md' 85 | - src: 'LICENSE-APACHE' 86 | dst: '/usr/share/doc/is/LICENSE-APACHE' 87 | - src: 'LICENSE-MIT' 88 | dst: '/usr/share/doc/is/LICENSE-MIT' 89 | - src: 'README.md' 90 | dst: '/usr/share/doc/is/README.md' 91 | 92 | changelog: 93 | sort: asc 94 | filters: 95 | exclude: 96 | - "^docs:" 97 | - "^test:" 98 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | --- 2 | name: test 3 | 4 | on: 5 | pull_request: 6 | push: 7 | branches: 8 | - main 9 | tags-ignore: 10 | - '**' 11 | workflow_dispatch: 12 | workflow_call: 13 | 14 | jobs: 15 | linux: 16 | runs-on: ubuntu-latest 17 | steps: 18 | - uses: actions/checkout@v6 19 | 20 | - name: Set up Go 21 | uses: actions/setup-go@v6 22 | with: 23 | go-version: 1.24.4 24 | 25 | - uses: actions/setup-node@v6 26 | with: 27 | node-version: 22 28 | 29 | - name: Install bats 30 | run: npm install -g bats 31 | 32 | - name: Display summaries 33 | run: | 34 | go build . 35 | ./is known summary os --md >> $GITHUB_STEP_SUMMARY 36 | ./is known summary var --md >> $GITHUB_STEP_SUMMARY 37 | 38 | - name: Test everything 39 | run: ./bin/test-everything 40 | 41 | - name: Upload coverage to Codecov 42 | uses: codecov/codecov-action@v5 43 | with: 44 | token: ${{ secrets.CODECOV_TOKEN }} 45 | 46 | macos: 47 | runs-on: macos-latest 48 | steps: 49 | - uses: actions/checkout@v6 50 | 51 | - name: Set up Go 52 | uses: actions/setup-go@v6 53 | with: 54 | go-version: 1.24.4 55 | 56 | - uses: actions/setup-node@v6 57 | with: 58 | node-version: 22 59 | 60 | - name: Install bats 61 | run: npm install -g bats 62 | 63 | - name: Install executables required for testing 64 | run: brew install tmux 65 | 66 | - name: Display summaries 67 | run: | 68 | go build . 69 | ./is known summary os --md >> $GITHUB_STEP_SUMMARY 70 | ./is known summary var --md >> $GITHUB_STEP_SUMMARY 71 | 72 | - name: Test everything 73 | run: ./bin/test-everything 74 | 75 | - name: Upload coverage to Codecov 76 | uses: codecov/codecov-action@v5 77 | with: 78 | token: ${{ secrets.CODECOV_TOKEN }} 79 | precious: 80 | runs-on: ubuntu-latest 81 | steps: 82 | - uses: actions/checkout@v6 83 | 84 | - name: Set up Go 85 | uses: actions/setup-go@v6 86 | with: 87 | go-version: 1.24.4 88 | 89 | - name: Install omegasort and precious 90 | uses: oalders/install-ubi-action@v0.0.6 91 | with: 92 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 93 | projects: | 94 | houseabsolute/omegasort 95 | houseabsolute/precious 96 | 97 | - name: Install Golang linters 98 | run: ./bin/install-go-linters 99 | 100 | - name: Run precious 101 | run: precious lint --all 102 | -------------------------------------------------------------------------------- /there.go: -------------------------------------------------------------------------------- 1 | // package main contains the logic for the "there" command 2 | package main 3 | 4 | import ( 5 | "errors" 6 | "fmt" 7 | "log" 8 | "os/exec" 9 | "strings" 10 | 11 | "github.com/oalders/is/types" 12 | ) 13 | 14 | // Run "is there ...". 15 | func (r *ThereCmd) Run(ctx *types.Context) error { 16 | if !r.All && !r.Verbose && !r.JSON { 17 | err := runCommand(ctx, r.Name) 18 | if err == nil { 19 | ctx.Success = true 20 | return nil 21 | } 22 | if ctx.Debug { 23 | log.Printf("🚀 which %s\n", r.Name) 24 | log.Printf("💥 %v\n", err) 25 | } 26 | } 27 | 28 | err := runWhich(ctx, r.Name, r.All, r.JSON) 29 | if err != nil { 30 | if e := (&exec.ExitError{}); errors.As(err, &e) { 31 | return nil 32 | } 33 | return err 34 | } 35 | ctx.Success = true 36 | return nil 37 | } 38 | 39 | func runCommand(ctx *types.Context, name string) error { 40 | args := []string{"-c", "command -v " + name} 41 | if ctx.Debug { 42 | log.Printf("🚀 sh -c %q\n", strings.Join(args[1:], " ")) 43 | } 44 | cmd := exec.CommandContext(ctx.Context, "sh", args...) 45 | output, err := cmd.Output() 46 | if ctx.Debug && len(output) != 0 { 47 | log.Printf("😅 %s", output) 48 | } 49 | return err //nolint:wrapcheck 50 | } 51 | 52 | //nolint:cyclop 53 | func runWhich(ctx *types.Context, name string, all, asJSON bool) error { 54 | args := []string{name} 55 | if all { 56 | args = append([]string{"-a"}, args...) 57 | } 58 | cmd := exec.CommandContext(ctx.Context, "which", args...) 59 | output, err := cmd.Output() 60 | if ctx.Debug { 61 | log.Printf("Running: which %s", strings.Join(args, " ")) 62 | if len(output) != 0 { 63 | log.Printf("😅 %s", output) 64 | } 65 | if err != nil { 66 | log.Printf("💥 %v\n", err) 67 | } 68 | } 69 | if err != nil { 70 | return fmt.Errorf("command run error: %w", err) 71 | } 72 | found := strings.Split(strings.TrimSpace( 73 | string(output), 74 | ), "\n") 75 | 76 | if asJSON { 77 | results := make([]map[string]string, 0, len(found)) 78 | for _, v := range found { 79 | version, err := runCLI(ctx, v) 80 | if err != nil { 81 | return err 82 | } 83 | results = append(results, map[string]string{"path": v, "version": version}) 84 | } 85 | encoded, err := toJSON(results) 86 | if err != nil { 87 | return err 88 | } 89 | success(ctx, encoded) 90 | return nil 91 | } 92 | 93 | headers := []string{ 94 | "Path", 95 | "Version", 96 | } 97 | 98 | rows := make([][]string, 0, len(found)) 99 | 100 | for _, path := range found { 101 | version, err := runCLI(ctx, path) 102 | if err != nil { 103 | return err 104 | } 105 | rows = append(rows, []string{path, version}) 106 | } 107 | success(ctx, tabular(headers, rows, false)) 108 | return nil 109 | } 110 | -------------------------------------------------------------------------------- /test/cli.bats: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bats 2 | 3 | bats_require_minimum_version 1.5.0 4 | 5 | semver=./testdata/bin/semver 6 | tmux=./testdata/bin/tmux 7 | 8 | @test 'cli age' { 9 | ./is cli age $tmux gt 1 s 10 | run ! ./is cli age $tmux lt 1 s 11 | } 12 | 13 | @test 'cli unimplemented subcommand' { 14 | run ! ./is cli Zage $tmux gt 1 s 15 | } 16 | 17 | @test 'output' { 18 | ./is cli output stdout date like '\d' 19 | } 20 | 21 | @test 'output with pipe' { 22 | ./is cli output stdout bash --arg='-c' --arg='date|wc -l' eq 1 23 | } 24 | 25 | @test 'succinct output with pipe' { 26 | ./is cli output stdout 'bash -c' -a 'date|wc -l' eq 1 27 | } 28 | 29 | @test 'output with pipe and --compare integer' { 30 | ./is cli output stdout --compare integer 'bash -c' -a 'date|wc -l' eq 1 31 | } 32 | 33 | @test 'output with pipe and --compare float' { 34 | ./is cli output stdout --compare float 'bash -c' -a 'date|wc -l' eq 1 35 | } 36 | 37 | @test 'output with pipe and --compare string' { 38 | ./is cli output stdout --compare string 'bash -c' -a 'date|wc -l' eq 1 39 | } 40 | 41 | @test 'output with pipe and --compare version' { 42 | ./is cli output stdout --compare version 'bash -c' -a 'date|wc -l' eq 1 43 | } 44 | 45 | @test 'output with pipe and --compare optimistic' { 46 | ./is cli output stdout --compare optimistic 'bash -c' -a 'date|wc -l' eq 1 47 | } 48 | 49 | @test 'output gt' { 50 | ./is cli output stdout 'bash -c' -a 'date|wc -l' gt 0 51 | } 52 | 53 | @test 'output gte' { 54 | ./is cli output stdout 'bash -c' -a 'date|wc -l' gte 1 55 | } 56 | 57 | @test 'output lt' { 58 | ./is cli output stdout 'bash -c' -a 'date|wc -l' lt 2 59 | } 60 | 61 | @test 'output lte' { 62 | ./is cli output stdout 'bash -c' -a 'date|wc -l' lte 1 63 | } 64 | 65 | @test 'output like' { 66 | ./is cli output stdout 'bash -c' -a 'date|wc -l' like 1 67 | } 68 | 69 | @test 'output unlike' { 70 | ./is cli output stdout 'bash -c' -a 'date|wc -l' unlike 111 71 | } 72 | 73 | @test 'output gte negative integer' { 74 | ./is cli output stdout 'bash -c' -a 'date|wc -l' gte --compare integer -- -1 75 | } 76 | 77 | @test 'output in' { 78 | ./is cli output --debug stdout $semver --arg="--version" in 1.2.3,3.2.1 79 | } 80 | 81 | @test 'major' { 82 | ./is cli version --major $semver eq 1 83 | } 84 | 85 | @test 'minor' { 86 | ./is cli version --minor $semver eq 2 87 | } 88 | 89 | @test 'patch' { 90 | ./is cli version --patch $semver eq 3 91 | } 92 | 93 | @test 'version in (float)' { 94 | ./is cli version $tmux in "3.2,3.3a" 95 | } 96 | 97 | @test 'version in' { 98 | ./is cli version $semver in 1.2.3,1.2.4,1.2.5 99 | } 100 | 101 | @test 'version --major in' { 102 | ./is cli version --major $semver in 1,4,5 103 | } 104 | 105 | @test 'unspecified patch in output' { 106 | ./is cli version --patch $tmux eq 0 107 | } 108 | 109 | @test 'string in' { 110 | ./is cli output stdout date --arg="+%a" in Mon,Tue,Wed,Thu,Fri,Sat,Sun 111 | } 112 | 113 | @test 'command with arguments - uname -a' { 114 | ./is os name ne linux && skip "Linux-only test" 115 | ./is cli output stdout "uname -a" like "Linux" 116 | } 117 | 118 | @test 'command with arguments - uname -m' { 119 | ./is os name ne linux && skip "Linux-only test" 120 | ./is cli output stdout "uname -m" like "x86_64" 121 | } 122 | -------------------------------------------------------------------------------- /CLAUDE.md: -------------------------------------------------------------------------------- 1 | # CLAUDE.md 2 | 3 | This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. 4 | 5 | ## Project Overview 6 | 7 | `is` is a Go-based CLI tool that serves as an inspector for your environment. It allows users to check various system attributes like OS details, CLI versions, battery status, audio levels, environment variables, and more. The tool uses exit codes (0 for success, 1 for failure) to enable scripting and conditional execution. 8 | 9 | ## Development Commands 10 | 11 | ### Build and Run 12 | ```bash 13 | # Build the project 14 | go build -o is . 15 | 16 | # Run directly with Go 17 | go run . --help 18 | 19 | # Build using the container script (requires Docker) 20 | ./bin/container-build 21 | ``` 22 | 23 | ### Testing 24 | ```bash 25 | # Run all Go tests 26 | go test ./... 27 | 28 | # Run specific tests 29 | go test ./compare 30 | go test ./parser 31 | 32 | # Run BATS integration tests (requires bash and bats) 33 | apk add bash # if running in Alpine container 34 | bats test/ 35 | ``` 36 | 37 | ### Code Quality 38 | ```bash 39 | # Format code with gofumpt 40 | gofumpt -w **/*.go 41 | 42 | # Format imports 43 | goimports -w **/*.go 44 | 45 | # Run linter 46 | golangci-lint run -c .golangci.yaml 47 | 48 | # Run all code quality tools via precious 49 | precious lint 50 | precious tidy 51 | ``` 52 | 53 | ### Container Development 54 | ```bash 55 | # Start development environment 56 | docker compose run --rm dev sh 57 | 58 | # Build inside container 59 | ./bin/container-build 60 | ``` 61 | 62 | ## Architecture 63 | 64 | ### Core Structure 65 | - **main.go**: Entry point using Kong CLI parser, defines all top-level commands 66 | - **api.go**: Type definitions for all CLI command structures and validation 67 | - **types/types.go**: Common types including Context struct used throughout 68 | - **Command handlers**: Each top-level command (arch, audio, battery, cli, fso, os, there, user, var, known) has its own .go file 69 | 70 | ### Key Packages 71 | - **age/**: Time-based comparisons for files and commands 72 | - **attr/**: Attribute extraction utilities 73 | - **audio/**: Audio level and mute status detection 74 | - **battery/**: Battery information retrieval 75 | - **command/**: Command execution with output capture 76 | - **compare/**: Value comparison logic (string, numeric, version, etc.) 77 | - **mac/**: macOS-specific functionality 78 | - **ops/**: Operation definitions and mappings 79 | - **os/**: Operating system detection and information 80 | - **parser/**: CLI output parsing and version extraction 81 | - **reader/**: File reading utilities 82 | - **version/**: Version comparison utilities 83 | 84 | ### Command Categories 85 | 1. **System Info**: `arch`, `os`, `audio`, `battery`, `user` 86 | 2. **CLI Tools**: `cli version`, `cli age`, `cli output`, `there` 87 | 3. **Files**: `fso age` 88 | 4. **Environment**: `var` 89 | 5. **Information Display**: `known` (prints values without comparisons) 90 | 91 | ### Comparison Operators 92 | The tool supports various comparison operators: `eq`, `ne`, `gt`, `gte`, `lt`, `lte`, `in`, `like` (regex), `unlike` (inverse regex) 93 | 94 | ### Testing Strategy 95 | - Unit tests for individual packages (`*_test.go`) 96 | - Integration tests using BATS framework in `test/` directory 97 | - Test data in `testdata/` directory 98 | 99 | ### Key Design Patterns 100 | - Kong-based CLI parsing with struct tags for command definitions 101 | - Context pattern for passing debug flags and success state 102 | - Modular architecture with separate packages for different functionalities 103 | - Exit code based success/failure for shell scripting integration -------------------------------------------------------------------------------- /battery/battery.go: -------------------------------------------------------------------------------- 1 | package battery 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | "log" 7 | "math" 8 | "reflect" 9 | "strings" 10 | 11 | "github.com/distatus/battery" 12 | "github.com/oalders/is/types" 13 | ) 14 | 15 | //nolint:tagliatelle 16 | type Battery struct { 17 | State string `json:"state"` 18 | BatteryNumber int `json:"battery-number"` 19 | Count int `json:"count"` 20 | ChargeRate float64 `json:"charge-rate"` 21 | CurrentCapacity float64 `json:"current-capacity"` 22 | CurrentCharge float64 `json:"current-charge"` 23 | DesignCapacity float64 `json:"design-capacity"` 24 | DesignVoltage float64 `json:"design-voltage"` 25 | LastFullCapacity float64 `json:"last-full-capacity"` 26 | Voltage float64 `json:"voltage"` 27 | } 28 | 29 | var attributeMap = map[string]string{ //nolint: gochecknoglobals 30 | "charge-rate": "ChargeRate", 31 | "count": "Count", 32 | "current-capacity": "CurrentCapacity", 33 | "current-charge": "CurrentCharge", 34 | "design-capacity": "DesignCapacity", 35 | "design-voltage": "DesignVoltage", 36 | "last-full-capacity": "LastFullCapacity", 37 | "state": "State", 38 | "voltage": "Voltage", 39 | } 40 | 41 | func Get(ctx *types.Context, nth int) (*Battery, error) { 42 | if nth == 0 { 43 | return nil, errors.New("use --nth 1 to get the first battery") 44 | } 45 | batteries, err := battery.GetAll() 46 | if err != nil { 47 | return nil, fmt.Errorf("get battery info: %w", err) 48 | } 49 | count := len(batteries) 50 | 51 | // All other attribute checks should generate an error message if no 52 | // batteries are found. 53 | // https://github.com/distatus/battery/issues/34 54 | //nolint:lll 55 | if count == 0 || (count == 1 && batteries[0].Current == 0 && batteries[0].Full == 0 && batteries[0].Design == 0) { 56 | return &Battery{Count: 0}, nil 57 | } 58 | if nth > count { 59 | return nil, fmt.Errorf( 60 | "battery %d requested, but only %d batteries found", 61 | nth, 62 | len(batteries), 63 | ) 64 | } 65 | battery := batteries[nth-1] 66 | 67 | batt := Battery{ 68 | BatteryNumber: nth, 69 | Count: len(batteries), 70 | ChargeRate: battery.ChargeRate, 71 | CurrentCapacity: battery.Current, 72 | CurrentCharge: math.Round(battery.Current / battery.Full * 100), 73 | DesignCapacity: battery.Design, 74 | DesignVoltage: battery.DesignVoltage, 75 | LastFullCapacity: battery.Full, 76 | State: strings.ToLower(battery.State.String()), 77 | Voltage: battery.Voltage, 78 | } 79 | 80 | if ctx.Debug { 81 | log.Printf( 82 | "Run %q to see all available battery data\n", 83 | "is known summary battery", 84 | ) 85 | } 86 | return &batt, nil 87 | } 88 | 89 | func GetAttrAsString(ctx *types.Context, attr string, round bool, nth int) (string, error) { 90 | fieldValue, err := GetAttr(ctx, attr, nth) 91 | if err != nil { 92 | return "", err 93 | } 94 | 95 | switch value := fieldValue.(type) { 96 | case float64: 97 | if round { 98 | return fmt.Sprintf("%d", int(math.Round(value))), nil 99 | } 100 | return fmt.Sprintf("%f", value), nil 101 | default: 102 | return fmt.Sprintf("%v", value), nil 103 | } 104 | } 105 | 106 | func GetAttr(ctx *types.Context, attr string, nth int) (any, error) { 107 | batt, err := Get(ctx, nth) 108 | if err != nil { 109 | return "", err 110 | } 111 | 112 | fieldName, ok := attributeMap[attr] 113 | if !ok { 114 | return "", fmt.Errorf("attr %s not recognized", attr) 115 | } 116 | 117 | return reflect.ValueOf(*batt).FieldByName(fieldName).Interface(), nil 118 | } 119 | -------------------------------------------------------------------------------- /os_test.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "runtime" 7 | "testing" 8 | 9 | "github.com/oalders/is/attr" 10 | "github.com/oalders/is/ops" 11 | "github.com/oalders/is/os" 12 | "github.com/oalders/is/types" 13 | "github.com/stretchr/testify/assert" 14 | ) 15 | 16 | func TestOSInfo(t *testing.T) { 17 | t.Parallel() 18 | tests := []string{attr.Name, attr.Version} 19 | 20 | if runtime.GOOS == "linux" { 21 | tests = append(tests, "id", "pretty-name") 22 | ctx := types.Context{ 23 | Context: context.Background(), 24 | Debug: true, 25 | } 26 | found, err := os.Info(&ctx, "name") 27 | assert.NoError(t, err) 28 | 29 | // id-like not present in Debian 11, so can't be in a blanket Linux 30 | // test 31 | if found == "ubuntu" { 32 | tests = append(tests, "id-like") 33 | } 34 | } 35 | 36 | for _, attr := range tests { 37 | ctx := types.Context{ 38 | Context: context.Background(), 39 | Debug: true, 40 | } 41 | found, err := os.Info(&ctx, attr) 42 | assert.NoError(t, err, attr) 43 | assert.NotEmpty(t, found, attr) 44 | } 45 | } 46 | 47 | func TestOSCmd(t *testing.T) { 48 | t.Parallel() 49 | type OSTest struct { 50 | Cmd OSCmd 51 | Error bool 52 | Success bool 53 | } 54 | 55 | const major = false 56 | const minor = false 57 | const patch = false 58 | 59 | tests := []OSTest{ 60 | {OSCmd{attr.Name, ops.Eq, "zzz", major, minor, patch}, false, false}, 61 | {OSCmd{attr.Name, ops.Ne, "zzz", major, minor, patch}, false, true}, 62 | {OSCmd{attr.Version, ops.Eq, "1", major, minor, patch}, false, false}, 63 | {OSCmd{attr.Version, ops.Ne, "1", major, minor, patch}, false, true}, 64 | {OSCmd{attr.Version, ops.Eq, "[*&1.1.1.1.1", major, minor, patch}, true, false}, 65 | {OSCmd{attr.Name, ops.Like, "zzz", major, minor, patch}, false, false}, 66 | {OSCmd{attr.Name, ops.Like, ".*", major, minor, patch}, false, true}, 67 | {OSCmd{attr.Name, ops.Unlike, "zzz", major, minor, patch}, false, true}, 68 | {OSCmd{attr.Name, ops.Unlike, ".*", major, minor, patch}, false, false}, 69 | {OSCmd{attr.Name, ops.Unlike, "[", major, minor, patch}, true, false}, 70 | {OSCmd{attr.Version, ops.Like, "xxx", major, minor, patch}, false, false}, 71 | {OSCmd{attr.Version, ops.Like, ".*", major, minor, patch}, false, true}, 72 | {OSCmd{attr.Version, ops.Like, "[+", major, minor, patch}, true, false}, 73 | {OSCmd{attr.Version, ops.Unlike, "xxX", major, minor, patch}, false, true}, 74 | {OSCmd{attr.Version, ops.Unlike, ".*", major, minor, patch}, false, false}, 75 | {OSCmd{attr.Version, ops.Unlike, "[+", major, minor, patch}, true, false}, 76 | {OSCmd{attr.Version, ops.Gt, "0", true, minor, patch}, false, true}, 77 | {OSCmd{attr.Version, ops.Gte, "0", major, true, patch}, false, true}, 78 | {OSCmd{attr.Version, ops.Gte, "0", major, minor, true}, false, true}, 79 | {OSCmd{attr.Name, ops.Gt, "0", true, minor, patch}, true, false}, 80 | {OSCmd{attr.Name, ops.Gt, "0", major, true, patch}, true, false}, 81 | {OSCmd{attr.Name, ops.Gt, "0", major, minor, true}, true, false}, 82 | } 83 | 84 | for _, test := range tests { 85 | ctx := types.Context{ 86 | Context: context.Background(), 87 | Debug: true, 88 | } 89 | err := test.Cmd.Run(&ctx) 90 | name := fmt.Sprintf( 91 | "%s %s %s major: %t minor: %t patch: %t", 92 | test.Cmd.Attr, 93 | test.Cmd.Op, 94 | test.Cmd.Val, 95 | test.Cmd.Major, 96 | test.Cmd.Minor, 97 | test.Cmd.Patch, 98 | ) 99 | if test.Error { 100 | assert.Error(t, err, "has error "+name) 101 | } else { 102 | assert.NoError(t, err, "has no error "+name) 103 | } 104 | if test.Success { 105 | assert.True(t, ctx.Success, "has success "+name) 106 | } else { 107 | assert.False(t, ctx.Success, "has no success "+name) 108 | } 109 | } 110 | } 111 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changes 2 | 3 | ## 0.11.0 - 2025-08-28 4 | 5 | - Add "is var [name] true" 6 | - Add "is var [name] false" 7 | 8 | ## 0.10.0 - 2025-08-28 9 | 10 | - Add "is audio" 11 | - Add "is known audio" 12 | 13 | ## 0.9.0 - 2025-06-27 14 | 15 | - Add "is known summary var" 16 | - Add "is known summary var --json" 17 | - Add "is known summary var --md" 18 | - Add "is known summary os --md" 19 | 20 | ## 0.8.2 - 2025-06-17 21 | 22 | - Fix gopls version parsing 23 | 24 | ## 0.8.1 - 2025-05-30 25 | 26 | - Fix a bug where "id" showed up twice in a Linux os info summary 27 | 28 | ## 0.8.0 - 2025-05-10 29 | 30 | - Replace some debugging output via: 31 | - Add "is known summary battery" 32 | - Add "is known summary battery --json" 33 | - Add "is known summary os" 34 | - Add "is known summary os --json" 35 | - Add "is there [binary-name] --verbose" 36 | - Add "is there [binary-name] --verbose --json" 37 | - Wrap "which -a" 38 | - Add "is there [binary-name] --all" 39 | - Add "is there [binary-name] --all --json" 40 | 41 | ## 0.7.0 - 2025-04-18 42 | 43 | - Add "is battery" and "is known battery" subcommands 44 | - Add version parsing for golangci-lint 45 | - Add version parsing for gopls 46 | - Suppress "is user sudoer" error message when "sudo" is not installed 47 | 48 | ## 0.6.1 - 2025-01-27 49 | 50 | - Allow for empty string comparisons via "eq" and "ne" in "is var". e.g. 51 | - is var FOO eq "" 52 | - is var FOO ne "" 53 | 54 | ## 0.6.0 - 2025-01-20 55 | 56 | - Add var subcommand 57 | - Add sequoia to macos code names 58 | 59 | ## 0.5.5 - 2024-10-29 60 | 61 | - Add version parsing for hugo 62 | 63 | ## 0.5.4 - 2024-08-16 64 | 65 | - Run "command -v" via "sh -c" (GH#37) (Olaf Alders) 66 | 67 | ## 0.5.3 - 2024-06-22 68 | 69 | - Parse versions for: dig, perldoc, fpp, fzf, screen, sqlite3 and typos (GH#35) 70 | (Olaf Alders) 71 | - Improve completion documentation 72 | 73 | ## 0.5.2 - 2024-06-18 74 | 75 | - Add some simple command line completion (GH#20) (Olaf Alders) 76 | 77 | ## 0.5.1 - 2024-06-18 78 | 79 | - Add NetBSD and FreeBSD to build targets (GH#33) (Jason A. Crome) 80 | 81 | ## 0.5.0 - 2024-06-08 82 | 83 | - Add fso subcommand (GH#32) (Olaf Alders) 84 | - Fix neovim nightly version parsing (GH#28) 85 | 86 | ## 0.4.3 - 2024-03-05 87 | 88 | - Add OCaml toolchain (GH#27) (Rawley) 89 | 90 | ## 0.4.2 - 2023-11-27 91 | 92 | - Fix cli version parsing for "rustc" 93 | - Add "sonoma" to macOS codenames 94 | 95 | ## 0.4.1 - 2023-09-28 96 | 97 | - Ensure stringy comparison of "in" via optimistic compare 98 | 99 | ## 0.4.0 - 2023-09-27 100 | 101 | - Add "in" for matching on items in a comma-delimited list 102 | 103 | ## 0.3.0 - 2023-09-24 104 | 105 | - Add --major, --minor and --patch version segment constraints 106 | 107 | ## 0.2.0 - 2023-09-15 108 | 109 | - Add "is command output" 110 | 111 | ## 0.1.2 - 2023-09-08 112 | 113 | - Improve docs 114 | - Re-organize internals 115 | 116 | ## 0.1.1 - 2023-08-16 117 | 118 | - Add better fallback version parsing 119 | - Add "is user sudoer" 120 | 121 | ## 0.1.0 - 2023-07-05 122 | 123 | - Add h|hour|hours to cli age units 124 | 125 | ## 0.0.9 - 2023-07-04 126 | 127 | - Add "cli age" 128 | 129 | ## 0.0.8 - 2023-06-23 130 | 131 | - Parse openssl versions 132 | - Add a linux release for arm 7 133 | 134 | ## 0.0.7 - 2023-06-15 135 | 136 | - Remove "os arch" which was badly named and not necessarily correct 137 | 138 | ## 0.0.6 - 2023-06-14 139 | 140 | - "command-version" is now "cli version" 141 | - Add more os attributes and attribute comparisons 142 | 143 | ## 0.0.5 - 2023-05-29 144 | 145 | - Silence error output when a command is not found 146 | 147 | ## 0.0.4 - 2023-05-26 148 | 149 | - Add a --version flag 150 | 151 | ## 0.0.3 - 2023-05-20 152 | 153 | - Tweak goreleaser config 154 | 155 | ## 0.0.2 - 2023-05-20 156 | 157 | - Test goreleaser GitHub action 158 | 159 | ## 0.0.1 - 2023-05-20 160 | 161 | - First release upon an unsuspecting world. 162 | -------------------------------------------------------------------------------- /cli.go: -------------------------------------------------------------------------------- 1 | // Package main contains the logic for the "cli" command 2 | package main 3 | 4 | import ( 5 | "fmt" 6 | "log" 7 | "os" 8 | "os/exec" 9 | "strings" 10 | "time" 11 | 12 | "github.com/oalders/is/age" 13 | "github.com/oalders/is/command" 14 | "github.com/oalders/is/compare" 15 | "github.com/oalders/is/ops" 16 | "github.com/oalders/is/parser" 17 | "github.com/oalders/is/types" 18 | ) 19 | 20 | func execCommand(ctx *types.Context, stream, cmdLine string, args []string) (string, error) { 21 | cmd, cmdArgs := parseCommand(cmdLine, args) 22 | 23 | if ctx.Debug { 24 | log.Printf("Running command %s with args: %v", cmd, cmdArgs) 25 | } 26 | 27 | execCmd := exec.CommandContext(ctx.Context, cmd, cmdArgs...) 28 | return command.Output(execCmd, stream) 29 | } 30 | 31 | func parseCommand(cmdLine string, args []string) (string, []string) { 32 | if cmdLine == "bash -c" { 33 | return "bash", append([]string{"-c"}, args...) 34 | } 35 | 36 | // If no explicit args provided via --arg flags, parse command line for embedded arguments 37 | if len(args) == 0 { 38 | // Split command on spaces to extract command and its arguments 39 | parts := strings.Fields(cmdLine) 40 | if len(parts) > 1 { 41 | return parts[0], parts[1:] 42 | } 43 | } 44 | 45 | return cmdLine, args 46 | } 47 | 48 | // Run "is cli ...". 49 | func (r *CLICmd) Run(ctx *types.Context) error { 50 | if r.Age.Name != "" { 51 | success, err := runCliAge(ctx, r.Age.Name, r.Age.Op, r.Age.Val, r.Age.Unit) 52 | ctx.Success = success 53 | return err 54 | } 55 | if r.Version.Name != "" { 56 | output, parserErr := parser.CLIOutput(ctx, r.Version.Name) 57 | if parserErr != nil { 58 | return parserErr 59 | } 60 | var success bool 61 | var err error 62 | switch { 63 | case r.Version.Major: 64 | success, err = compare.VersionSegment(ctx, r.Version.Op, output, r.Version.Val, 0) 65 | ctx.Success = success 66 | return err 67 | case r.Version.Minor: 68 | success, err = compare.VersionSegment(ctx, r.Version.Op, output, r.Version.Val, 1) 69 | ctx.Success = success 70 | return err 71 | case r.Version.Patch: 72 | success, err = compare.VersionSegment(ctx, r.Version.Op, output, r.Version.Val, 2) 73 | ctx.Success = success 74 | return err 75 | } 76 | 77 | success, err = compare.Versions(ctx, r.Version.Op, output, r.Version.Val) 78 | ctx.Success = success 79 | return err 80 | } 81 | 82 | output, err := execCommand(ctx, r.Output.Stream, r.Output.Command, r.Output.Arg) 83 | if err != nil { 84 | return err 85 | } 86 | 87 | success, err := compareOutput(ctx, r.Output.Compare, r.Output.Op, output, r.Output.Val) 88 | ctx.Success = success 89 | return err 90 | } 91 | 92 | func compareOutput( 93 | ctx *types.Context, 94 | comparisonType, operator, output, want string, 95 | ) (bool, error) { 96 | switch comparisonType { 97 | case "string": 98 | return compare.Strings(ctx, operator, output, want) 99 | case "version": 100 | return compare.Versions(ctx, operator, output, want) 101 | case "integer": 102 | return compare.Integers(ctx, operator, output, want) 103 | case "float": 104 | return compare.Floats(ctx, operator, output, want) 105 | default: 106 | return compare.Optimistic(ctx, operator, output, want), nil 107 | } 108 | } 109 | 110 | func compareAge(ctx *types.Context, modTime, targetTime time.Time, operator, path string) bool { 111 | // Returns -1 if cli age is older than target time 112 | // Returns 0 if they are the same 113 | // Returns 1 if cli age is younger than target time 114 | 115 | if ctx.Debug { 116 | translate := map[string]string{"gt": "before", "lt": "after"} 117 | log.Printf( 118 | "Comparison:\n%s (%s last modification)\n%s\n%s\n", 119 | modTime.Format("2006-01-02 15:04:05"), 120 | path, 121 | translate[operator], 122 | targetTime.Format("2006-01-02 15:04:05"), 123 | ) 124 | } 125 | 126 | compare := modTime.Compare(targetTime) 127 | if (operator == ops.Gt || operator == ops.Gte) && compare < 1 { 128 | return true 129 | } else if (operator == ops.Lt || operator == ops.Lte) && compare >= 0 { 130 | return true 131 | } 132 | return false 133 | } 134 | 135 | func runCliAge(ctx *types.Context, name, ageOperator, ageValue, ageUnit string) (bool, error) { 136 | path, err := exec.LookPath(name) 137 | if err != nil { 138 | return false, fmt.Errorf("could not find command: %w", err) 139 | } 140 | return runAge(ctx, path, ageOperator, ageValue, ageUnit) 141 | } 142 | 143 | func runAge(ctx *types.Context, path, ageOperator, ageValue, ageUnit string) (bool, error) { 144 | info, err := os.Stat(path) 145 | if err != nil { 146 | return false, fmt.Errorf("could not stat command: %w", err) 147 | } 148 | 149 | dur, err := age.StringToDuration(ageValue, ageUnit) 150 | if err != nil { 151 | return false, err 152 | } 153 | 154 | targetTime := time.Now().Add(*dur) 155 | return compareAge(ctx, info.ModTime(), targetTime, ageOperator, path), nil 156 | } 157 | -------------------------------------------------------------------------------- /parser/parser.go: -------------------------------------------------------------------------------- 1 | // Package parser contains output parsers 2 | package parser 3 | 4 | import ( 5 | "fmt" 6 | "io" 7 | "log" 8 | "os/exec" 9 | "path/filepath" 10 | "regexp" 11 | "strings" 12 | 13 | "github.com/oalders/is/types" 14 | ) 15 | 16 | func CLIOutput(ctx *types.Context, cliName string) (string, error) { 17 | versionArg := map[string]string{ 18 | "dig": "-v", 19 | "hugo": "version", 20 | "go": "version", 21 | "gopls": "version", 22 | "lua": "-v", 23 | "openssl": "version", 24 | "perldoc": "-V", 25 | "pihole": "-v", 26 | "ssh": "-V", 27 | "tmux": "-V", 28 | } 29 | arg := "--version" 30 | 31 | baseName := filepath.Base(cliName) // might be a path 32 | if v, exists := versionArg[baseName]; exists { 33 | arg = v 34 | } 35 | 36 | args := []string{cliName, arg} 37 | if ctx.Debug { 38 | log.Printf("Running: %s %s\n", args[0], args[1]) 39 | } 40 | cmd := exec.CommandContext(ctx.Context, cliName, arg) 41 | stdout, err := cmd.StdoutPipe() 42 | if err != nil { 43 | return "", fmt.Errorf("command output: %w", err) 44 | } 45 | stderr, err := cmd.StderrPipe() 46 | if err != nil { 47 | return "", fmt.Errorf("error output: %w", err) 48 | } 49 | 50 | if err := cmd.Start(); err != nil { 51 | return "", fmt.Errorf("starting command: %w", err) 52 | } 53 | 54 | output, _ := io.ReadAll(stdout) 55 | // ssh -V doesn't print to STDOUT? 56 | if len(output) == 0 { 57 | if ctx.Debug { 58 | log.Printf("Running: %s %s and checking STDERR\n", args[0], args[1]) 59 | } 60 | 61 | output, _ = io.ReadAll(stderr) 62 | } 63 | 64 | return CLIVersion(ctx, baseName, string(output)), nil 65 | } 66 | 67 | //nolint:funlen 68 | func CLIVersion(ctx *types.Context, cliName, output string) string { 69 | floatRegex := `\d+\.\d+` 70 | floatWithTrailingLetterRegex := `[\d.]*\w` 71 | intRegex := `\d*` 72 | optimisticRegex := `[\d.]*` 73 | semverRegex := `\d+\.\d+\.\d+` 74 | vStringRegex := `v[\d.]*` 75 | vStringWithTrailingLetterRegex := `v[\d.]*\w` 76 | vStringWithTrailingGreedyRegex := `v[\d.]*[\w+-]*\w` 77 | regexen := map[string]string{ 78 | "ansible": fmt.Sprintf(`ansible \[core (%s)\b`, semverRegex), 79 | "bash": fmt.Sprintf(`version (%s)\b`, semverRegex), 80 | "bat": fmt.Sprintf(`bat (%s)\b`, semverRegex), 81 | "csh": fmt.Sprintf(`(%s)`, semverRegex), 82 | "curl": fmt.Sprintf(`curl (%s)\b`, semverRegex), 83 | "docker": fmt.Sprintf(`version (%s),`, semverRegex), 84 | "fpp": fmt.Sprintf(`version (%s)\b`, semverRegex), 85 | "fzf": fmt.Sprintf(`(%s)\b`, semverRegex), 86 | "gcc": fmt.Sprintf(`clang version (%s)\b`, semverRegex), 87 | "git": fmt.Sprintf(`git version (%s)\s`, semverRegex), 88 | "gh": fmt.Sprintf(`gh version (%s)\b`, semverRegex), 89 | "go": fmt.Sprintf(`go version go(%s)\s`, semverRegex), 90 | "golangci-lint": fmt.Sprintf(`golangci-lint has version (%s)\s`, semverRegex), 91 | "gopls": fmt.Sprintf(`gopls v(%s)\b`, semverRegex), 92 | "grep": fmt.Sprintf(`(%s|%s)`, semverRegex, floatRegex), 93 | "hugo": fmt.Sprintf(`hugo v(%s)\b`, semverRegex), 94 | "jq": fmt.Sprintf(`jq-(%s)\b`, floatRegex), 95 | "less": fmt.Sprintf(`less (%s)\b`, intRegex), 96 | "lua": fmt.Sprintf(`Lua (%s)\b`, semverRegex), 97 | "md5sum": fmt.Sprintf(`md5sum \(GNU coreutils\) (%s)\b`, floatRegex), 98 | "nvim": fmt.Sprintf(`NVIM (%s)\b`, vStringWithTrailingGreedyRegex), 99 | "perl": fmt.Sprintf(`This is perl .* \((%s)\)\s`, vStringRegex), 100 | "ocaml": fmt.Sprintf(`The OCaml toplevel, version (%s)`, semverRegex), 101 | "opam": fmt.Sprintf(`(%s)`, semverRegex), 102 | "openssl": fmt.Sprintf(`SSL (%s)\b`, floatWithTrailingLetterRegex), 103 | "perldoc": fmt.Sprintf(`(%s)\b`, vStringRegex), 104 | "pihole": fmt.Sprintf(`Pi-hole version is (%s)`, vStringRegex), 105 | "plenv": `plenv ([\d\w\-\.]*)\b`, 106 | "python": fmt.Sprintf(`Python (%s)\b`, semverRegex), 107 | "python3": fmt.Sprintf(`Python (%s)\b`, semverRegex), 108 | "rg": fmt.Sprintf(`ripgrep (%s)\b`, semverRegex), 109 | "ruby": `ruby (\d+\.\d+\.[\d\w]+)\b`, 110 | "tcsh": fmt.Sprintf(`(%s)`, semverRegex), 111 | "rustc": fmt.Sprintf(`rustc (%s)\b`, semverRegex), 112 | "screen": fmt.Sprintf(`version (%s)\b`, semverRegex), 113 | "sh": fmt.Sprintf(`version (%s)\b`, semverRegex), 114 | "sqlite3": fmt.Sprintf(`(%s)\b`, semverRegex), 115 | "ssh": `OpenSSH_([0-9a-z.]*)\b`, 116 | "tar": fmt.Sprintf(`bsdtar (%s)\b`, semverRegex), 117 | "typos": fmt.Sprintf(`typos-cli (%s)\b`, semverRegex), 118 | "tmux": fmt.Sprintf(`tmux (%s)\b`, floatWithTrailingLetterRegex), 119 | "tree": fmt.Sprintf(`tree (%s)\b`, vStringWithTrailingLetterRegex), 120 | "trurl": fmt.Sprintf(`trurl version (%s)\b`, floatRegex), 121 | "unzip": fmt.Sprintf(`UnZip (%s)\b`, floatRegex), 122 | "vim": fmt.Sprintf(`VIM - Vi IMproved (%s)\b`, floatRegex), 123 | "zsh": fmt.Sprintf(`zsh (%s)\b`, floatRegex), 124 | } 125 | var versionRegex *regexp.Regexp 126 | hasNewLines := regexp.MustCompile("\n") 127 | if v, exists := regexen[cliName]; exists { 128 | versionRegex = regexp.MustCompile(v) 129 | } else if found := len(hasNewLines.FindAllStringIndex(output, -1)); found > 1 { 130 | // If --version returns more than one line, the actual version will 131 | // generally be the last thing on the first line 132 | versionRegex = regexp.MustCompile(fmt.Sprintf(`(?:\s)(%s|%s|%s|%s|%s|%s)\s*\n`, 133 | semverRegex, vStringWithTrailingLetterRegex, floatWithTrailingLetterRegex, 134 | vStringRegex, optimisticRegex, floatRegex)) 135 | } else { 136 | versionRegex = regexp.MustCompile(`(?i)` + cliName + `\s+(.*)\b`) 137 | } 138 | 139 | matches := versionRegex.FindAllStringSubmatch(output, -1) 140 | if ctx.Debug { 141 | log.Printf("matching output \"%s\" on regex \"%s\"\n", output, versionRegex) 142 | } 143 | if len(matches) > 0 { 144 | output = matches[0][1] 145 | } 146 | output = strings.TrimRight(output, "\n") 147 | 148 | return output 149 | } 150 | -------------------------------------------------------------------------------- /cli_test.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "context" 5 | "testing" 6 | 7 | "github.com/oalders/is/ops" 8 | "github.com/oalders/is/types" 9 | "github.com/stretchr/testify/assert" 10 | ) 11 | 12 | //nolint:paralleltest,nolintlint 13 | func TestCliVersion(t *testing.T) { 14 | const command = "tmux" 15 | t.Setenv("PATH", prependPath("testdata/bin")) 16 | 17 | type test struct { 18 | Cmp VersionCmp 19 | Error bool 20 | Success bool 21 | } 22 | 23 | major := false 24 | minor := false 25 | patch := false 26 | 27 | //nolint:godox 28 | tests := []test{ 29 | {VersionCmp{command, ops.Eq, "3.3a", major, minor, patch}, false, true}, 30 | {VersionCmp{command, ops.Gt, "3.2a", major, minor, patch}, false, true}, 31 | {VersionCmp{command, ops.Lt, "3.3b", major, minor, patch}, false, true}, 32 | {VersionCmp{command, ops.Lt, "4", major, minor, patch}, false, true}, 33 | {VersionCmp{command, ops.Ne, "1", major, minor, patch}, false, true}, 34 | {VersionCmp{"tmuxzzz", ops.Ne, "1", major, minor, patch}, true, false}, 35 | {VersionCmp{command, ops.Eq, "1", major, minor, patch}, false, false}, 36 | {VersionCmp{command, ops.Eq, "zzz", major, minor, patch}, true, false}, 37 | {VersionCmp{command, ops.Unlike, "zzz", major, minor, patch}, false, true}, 38 | {VersionCmp{command, ops.Like, "", major, minor, patch}, false, true}, // FIXME 39 | {VersionCmp{command, ops.Like, "3.*", major, minor, patch}, false, true}, 40 | {VersionCmp{command, ops.Eq, "3", true, minor, patch}, false, true}, 41 | {VersionCmp{command, ops.Eq, "3", major, true, patch}, false, true}, 42 | {VersionCmp{command, ops.Eq, "0", major, minor, true}, false, true}, 43 | } 44 | 45 | for _, test := range tests { 46 | ctx := &types.Context{ 47 | Context: context.Background(), 48 | Debug: true, 49 | } 50 | cmd := CLICmd{Version: test.Cmp} 51 | err := cmd.Run(ctx) 52 | if test.Error { 53 | assert.Error(t, err) 54 | } else { 55 | assert.NoError(t, err) 56 | } 57 | if test.Success { 58 | assert.True(t, ctx.Success) 59 | } else { 60 | assert.False(t, ctx.Success) 61 | } 62 | } 63 | } 64 | 65 | //nolint:paralleltest,nolintlint 66 | func TestCliAge(t *testing.T) { 67 | t.Setenv("PATH", prependPath("testdata/bin")) 68 | const command = "tmux" 69 | { 70 | ctx := &types.Context{ 71 | Context: context.Background(), 72 | Debug: true, 73 | } 74 | cmd := CLICmd{Age: AgeCmp{command, ops.Gt, "1", "s"}} 75 | err := cmd.Run(ctx) 76 | assert.NoError(t, err) 77 | assert.True(t, ctx.Success) 78 | } 79 | { 80 | ctx := &types.Context{ 81 | Context: context.Background(), 82 | Debug: true, 83 | } 84 | cmd := CLICmd{Age: AgeCmp{command, ops.Lt, "100000", "days"}} 85 | err := cmd.Run(ctx) 86 | assert.NoError(t, err) 87 | assert.True(t, ctx.Success) 88 | } 89 | { 90 | ctx := &types.Context{ 91 | Context: context.Background(), 92 | Debug: true, 93 | } 94 | cmd := CLICmd{Age: AgeCmp{command, ops.Lt, "1.1", "d"}} 95 | err := cmd.Run(ctx) 96 | assert.Error(t, err) 97 | assert.False(t, ctx.Success) 98 | } 99 | { 100 | ctx := &types.Context{ 101 | Context: context.Background(), 102 | Debug: true, 103 | } 104 | cmd := CLICmd{Age: AgeCmp{"tmuxxx", ops.Lt, "1", "d"}} 105 | err := cmd.Run(ctx) 106 | assert.Error(t, err) 107 | assert.False(t, ctx.Success) 108 | } 109 | } 110 | 111 | //nolint:paralleltest,nolintlint 112 | func TestCliOutput(t *testing.T) { 113 | t.Setenv("PATH", prependPath("testdata/bin")) 114 | type test struct { 115 | Cmp OutputCmp 116 | Error bool 117 | Success bool 118 | } 119 | 120 | command := "tmux" 121 | args := []string{"-V"} 122 | const optimistic = "optimistic" 123 | 124 | tests := []test{ 125 | {OutputCmp{"stdout", command, ops.Eq, "tmux 3.3a", args, optimistic}, false, true}, 126 | {OutputCmp{"stdout", command, ops.Ne, "1", args, optimistic}, false, true}, 127 | {OutputCmp{"stdout", command, ops.Eq, "1", args, optimistic}, false, false}, 128 | {OutputCmp{"stderr", command, ops.Like, "xxx", args, optimistic}, false, false}, 129 | {OutputCmp{"stderr", command, ops.Unlike, "xxx", args, optimistic}, false, true}, 130 | {OutputCmp{"combined", command, ops.Like, "xxx", args, optimistic}, false, false}, 131 | {OutputCmp{"combined", command, ops.Unlike, "xxx", args, optimistic}, false, true}, 132 | {OutputCmp{"stdout", command, ops.Ne, "1", args, "string"}, false, true}, 133 | {OutputCmp{"stdout", command, ops.Ne, "1", args, "integer"}, true, false}, 134 | {OutputCmp{"stdout", command, ops.Ne, "1", args, "version"}, true, false}, 135 | {OutputCmp{"stdout", command, ops.Ne, "1", args, "float"}, true, false}, 136 | {OutputCmp{"stdout", "bash -c", ops.Eq, "1", []string{"date|wc -l"}, "integer"}, false, true}, 137 | } 138 | 139 | for _, test := range tests { 140 | ctx := &types.Context{ 141 | Context: context.Background(), 142 | Debug: true, 143 | } 144 | cmd := CLICmd{Output: test.Cmp} 145 | err := cmd.Run(ctx) 146 | if test.Error { 147 | assert.Error(t, err) 148 | } else { 149 | assert.NoError(t, err) 150 | } 151 | if test.Success { 152 | assert.True(t, ctx.Success) 153 | } else { 154 | assert.False(t, ctx.Success) 155 | } 156 | } 157 | } 158 | 159 | //nolint:paralleltest,nolintlint 160 | func TestParseCommand(t *testing.T) { 161 | type test struct { 162 | cmdLine string 163 | args []string 164 | wantCmd string 165 | wantArgs []string 166 | description string 167 | } 168 | 169 | tests := []test{ 170 | { 171 | cmdLine: "uname", 172 | args: []string{}, 173 | wantCmd: "uname", 174 | wantArgs: []string{}, 175 | description: "simple command without args", 176 | }, 177 | { 178 | cmdLine: "uname -a", 179 | args: []string{}, 180 | wantCmd: "uname", 181 | wantArgs: []string{"-a"}, 182 | description: "command with embedded args", 183 | }, 184 | { 185 | cmdLine: "uname -m -n", 186 | args: []string{}, 187 | wantCmd: "uname", 188 | wantArgs: []string{"-m", "-n"}, 189 | description: "command with multiple embedded args", 190 | }, 191 | { 192 | cmdLine: "uname", 193 | args: []string{"-a", "-m"}, 194 | wantCmd: "uname", 195 | wantArgs: []string{"-a", "-m"}, 196 | description: "command with explicit --arg flags", 197 | }, 198 | { 199 | cmdLine: "bash -c", 200 | args: []string{"date|wc -l"}, 201 | wantCmd: "bash", 202 | wantArgs: []string{"-c", "date|wc -l"}, 203 | description: "special bash -c case", 204 | }, 205 | { 206 | cmdLine: "cat file.txt", 207 | args: []string{}, 208 | wantCmd: "cat", 209 | wantArgs: []string{"file.txt"}, 210 | description: "command with filename argument", 211 | }, 212 | } 213 | 214 | for _, test := range tests { 215 | cmd, args := parseCommand(test.cmdLine, test.args) 216 | assert.Equal(t, test.wantCmd, cmd, test.description) 217 | assert.Equal(t, test.wantArgs, args, test.description) 218 | } 219 | } 220 | -------------------------------------------------------------------------------- /test/var.bats: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bats 2 | 3 | bats_require_minimum_version 1.5.0 4 | 5 | setup() { 6 | export TEST_VAR="nvim" 7 | export NUM_VAR="42" 8 | export FLOAT_VAR="3.14" 9 | export BOOL_TRUE_VAR="true" 10 | export BOOL_FALSE_VAR="false" 11 | export BOOL_ONE_VAR="1" 12 | export BOOL_ZERO_VAR="0" 13 | export BOOL_T_VAR="t" 14 | export BOOL_F_VAR="f" 15 | export BOOL_TRUE_UPPER_VAR="TRUE" 16 | export BOOL_FALSE_UPPER_VAR="FALSE" 17 | export BOOL_INVALID_VAR="maybe" 18 | } 19 | 20 | teardown() { 21 | unset TEST_VAR 22 | unset NUM_VAR 23 | unset FLOAT_VAR 24 | unset BOOL_TRUE_VAR 25 | unset BOOL_FALSE_VAR 26 | unset BOOL_ONE_VAR 27 | unset BOOL_ZERO_VAR 28 | unset BOOL_T_VAR 29 | unset BOOL_F_VAR 30 | unset BOOL_TRUE_UPPER_VAR 31 | unset BOOL_FALSE_UPPER_VAR 32 | unset BOOL_INVALID_VAR 33 | } 34 | 35 | @test "is var TEST_VAR set" { 36 | run ./is var TEST_VAR set 37 | [ "$status" -eq 0 ] 38 | } 39 | 40 | @test "is var TEST_VAR unset" { 41 | run ./is var TEST_VAR unset 42 | [ "$status" -eq 1 ] 43 | } 44 | 45 | @test "is var TEST_VAR eq nvim" { 46 | run ./is var TEST_VAR eq nvim 47 | [ "$status" -eq 0 ] 48 | } 49 | 50 | @test "is var TEST_VAR eq vim" { 51 | run ./is var TEST_VAR eq vim 52 | [ "$status" -eq 1 ] 53 | } 54 | 55 | @test "is var TEST_VAR gt 1" { 56 | run ./is var TEST_VAR gt 1 57 | [ "$status" -eq 1 ] 58 | } 59 | 60 | @test "is var TEST_VAR like nv.*" { 61 | run ./is var TEST_VAR like "nv.*" 62 | [ "$status" -eq 0 ] 63 | } 64 | 65 | @test "is var TEST_VAR unlike nv.*" { 66 | run ./is var TEST_VAR unlike "nv.*" 67 | [ "$status" -eq 1 ] 68 | } 69 | 70 | @test "is var NON_EXISTENT_VAR set" { 71 | run ./is var NON_EXISTENT_VAR set 72 | [ "$status" -eq 1 ] 73 | } 74 | 75 | @test "is var NON_EXISTENT_VAR unset" { 76 | run ./is var NON_EXISTENT_VAR unset 77 | [ "$status" -eq 0 ] 78 | } 79 | 80 | @test "is var NUM_VAR eq 42 --compare integer" { 81 | run ./is var NUM_VAR eq 42 --compare integer 82 | [ "$status" -eq 0 ] 83 | } 84 | 85 | @test "is var NUM_VAR gt 40 --compare integer" { 86 | run ./is var NUM_VAR gt 40 --compare integer 87 | [ "$status" -eq 0 ] 88 | } 89 | 90 | @test "is var NUM_VAR lt 50 --compare integer" { 91 | run ./is var NUM_VAR lt 50 --compare integer 92 | [ "$status" -eq 0 ] 93 | } 94 | 95 | @test "is var FLOAT_VAR eq 3.14 --compare float" { 96 | run ./is var FLOAT_VAR eq 3.14 --compare float 97 | [ "$status" -eq 0 ] 98 | } 99 | 100 | @test "is var FLOAT_VAR gt 3.0 --compare float" { 101 | run ./is var FLOAT_VAR gt 3.0 --compare float 102 | [ "$status" -eq 0 ] 103 | } 104 | 105 | @test "is var FLOAT_VAR lt 4.0 --compare float" { 106 | run ./is var FLOAT_VAR lt 4.0 --compare float 107 | [ "$status" -eq 0 ] 108 | } 109 | 110 | @test "is var FLOAT_VAR eq 3.14 --compare integer should fail" { 111 | run ./is var FLOAT_VAR eq 3.14 --compare integer 112 | [ "$status" -eq 1 ] 113 | [[ "$output" == *"wanted result must be an integer"* ]] 114 | } 115 | 116 | @test "is var EMPTY_VAR eq '' should pass" { 117 | export EMPTY_VAR="" 118 | run ./is var EMPTY_VAR eq "" 119 | [ "$status" -eq 0 ] 120 | } 121 | 122 | # we can't determine the difference between eq "" and eq with no trailing arg 123 | @test "'is var EMPTY_VAR eq' should pass" { 124 | export EMPTY_VAR="" 125 | run ./is var EMPTY_VAR eq 126 | [ "$status" -eq 0 ] 127 | } 128 | 129 | @test "is var EMPTY_VAR eq \"\" should fail" { 130 | run ./is var EMPTY_VAR eq "" 131 | [ "$status" -eq 1 ] 132 | } 133 | 134 | @test "is var EMPTY_VAR set should pass" { 135 | export EMPTY_VAR="" 136 | run ./is var EMPTY_VAR set 137 | [ "$status" -eq 0 ] 138 | } 139 | 140 | @test "is var EMPTY_VAR unset should fail" { 141 | export EMPTY_VAR="" 142 | run ./is var EMPTY_VAR unset 143 | [ "$status" -eq 1 ] 144 | } 145 | 146 | @test "is var EMPTY_VAR ne '' should fail" { 147 | export EMPTY_VAR="" 148 | run ./is var EMPTY_VAR ne "" 149 | [ "$status" -eq 1 ] 150 | } 151 | 152 | # we can't determine the difference between ne "" and ne with no trailing arg 153 | @test "is var EMPTY_VAR ne should fail" { 154 | export EMPTY_VAR="" 155 | run ./is var EMPTY_VAR ne 156 | [ "$status" -eq 1 ] 157 | } 158 | 159 | @test "is var EMPTY_VAR ne 'non-empty' should pass" { 160 | export EMPTY_VAR="" 161 | run ./is var EMPTY_VAR ne "non-empty" 162 | [ "$status" -eq 0 ] 163 | } 164 | 165 | # Tests for the new 'true' subcommand 166 | @test "is var BOOL_TRUE_VAR true should pass" { 167 | run ./is var BOOL_TRUE_VAR true 168 | [ "$status" -eq 0 ] 169 | } 170 | 171 | @test "is var BOOL_FALSE_VAR true should fail" { 172 | run ./is var BOOL_FALSE_VAR true 173 | [ "$status" -eq 1 ] 174 | } 175 | 176 | @test "is var BOOL_ONE_VAR true should pass" { 177 | run ./is var BOOL_ONE_VAR true 178 | [ "$status" -eq 0 ] 179 | } 180 | 181 | @test "is var BOOL_ZERO_VAR true should fail" { 182 | run ./is var BOOL_ZERO_VAR true 183 | [ "$status" -eq 1 ] 184 | } 185 | 186 | @test "is var BOOL_T_VAR true should pass" { 187 | run ./is var BOOL_T_VAR true 188 | [ "$status" -eq 0 ] 189 | } 190 | 191 | @test "is var BOOL_F_VAR true should fail" { 192 | run ./is var BOOL_F_VAR true 193 | [ "$status" -eq 1 ] 194 | } 195 | 196 | @test "is var BOOL_TRUE_UPPER_VAR true should pass" { 197 | run ./is var BOOL_TRUE_UPPER_VAR true 198 | [ "$status" -eq 0 ] 199 | } 200 | 201 | @test "is var BOOL_FALSE_UPPER_VAR true should fail" { 202 | run ./is var BOOL_FALSE_UPPER_VAR true 203 | [ "$status" -eq 1 ] 204 | } 205 | 206 | @test "is var BOOL_INVALID_VAR true should fail with error" { 207 | run ./is var BOOL_INVALID_VAR true 208 | [ "$status" -eq 1 ] 209 | [[ "$output" == *"cannot be parsed as boolean"* ]] 210 | } 211 | 212 | @test "is var NON_EXISTENT_VAR true should fail with error" { 213 | run ./is var NON_EXISTENT_VAR true 214 | [ "$status" -eq 1 ] 215 | [[ "$output" == *"is not set"* ]] 216 | } 217 | 218 | # Tests for the new 'false' subcommand 219 | @test "is var BOOL_TRUE_VAR false should fail" { 220 | run ./is var BOOL_TRUE_VAR false 221 | [ "$status" -eq 1 ] 222 | } 223 | 224 | @test "is var BOOL_FALSE_VAR false should pass" { 225 | run ./is var BOOL_FALSE_VAR false 226 | [ "$status" -eq 0 ] 227 | } 228 | 229 | @test "is var BOOL_ONE_VAR false should fail" { 230 | run ./is var BOOL_ONE_VAR false 231 | [ "$status" -eq 1 ] 232 | } 233 | 234 | @test "is var BOOL_ZERO_VAR false should pass" { 235 | run ./is var BOOL_ZERO_VAR false 236 | [ "$status" -eq 0 ] 237 | } 238 | 239 | @test "is var BOOL_T_VAR false should fail" { 240 | run ./is var BOOL_T_VAR false 241 | [ "$status" -eq 1 ] 242 | } 243 | 244 | @test "is var BOOL_F_VAR false should pass" { 245 | run ./is var BOOL_F_VAR false 246 | [ "$status" -eq 0 ] 247 | } 248 | 249 | @test "is var BOOL_TRUE_UPPER_VAR false should fail" { 250 | run ./is var BOOL_TRUE_UPPER_VAR false 251 | [ "$status" -eq 1 ] 252 | } 253 | 254 | @test "is var BOOL_FALSE_UPPER_VAR false should pass" { 255 | run ./is var BOOL_FALSE_UPPER_VAR false 256 | [ "$status" -eq 0 ] 257 | } 258 | 259 | @test "is var BOOL_INVALID_VAR false should fail with error" { 260 | run ./is var BOOL_INVALID_VAR false 261 | [ "$status" -eq 1 ] 262 | [[ "$output" == *"cannot be parsed as boolean"* ]] 263 | } 264 | 265 | @test "is var NON_EXISTENT_VAR false should fail with error" { 266 | run ./is var NON_EXISTENT_VAR false 267 | [ "$status" -eq 1 ] 268 | [[ "$output" == *"is not set"* ]] 269 | } 270 | -------------------------------------------------------------------------------- /parser/parser_test.go: -------------------------------------------------------------------------------- 1 | package parser_test 2 | 3 | import ( 4 | "context" 5 | "testing" 6 | 7 | "github.com/oalders/is/parser" 8 | "github.com/oalders/is/types" 9 | "github.com/stretchr/testify/assert" 10 | ) 11 | 12 | const ( 13 | ssh = "../testdata/bin/ssh" 14 | tmux = "../testdata/bin/tmux" 15 | ) 16 | 17 | //nolint:lll 18 | func TestCLIVersion(t *testing.T) { 19 | t.Parallel() 20 | ctx := types.Context{ 21 | Context: context.Background(), 22 | } 23 | 24 | tests := [][]string{ 25 | {"ansible", "2.14.2", "ansible [core 2.14.2]"}, 26 | { 27 | "bash", 28 | "5.2.15", 29 | `GNU bash, version 5.2.15(1)-release (aarch64-apple-darwin22.1.0) 30 | Copyright (C) 2022 Free Software Foundation, Inc. 31 | License GPLv3+: GNU GPL version 3 or later 32 | 33 | This is free software; you are free to change and redistribute it. 34 | There is NO WARRANTY, to the extent permitted by law.`, 35 | }, 36 | {"bat", "0.23.0", "bat 0.23.0 (871abd2)"}, 37 | {"csh", "6.21.00", "tcsh 6.21.00 (Astron) 2019-05-08 (x86_64-apple-darwin) options wide,nls,dl,bye,al,kan,sm,rh,color,filec"}, 38 | { 39 | "curl", "7.88.1", 40 | `curl 7.88.1 (x86_64-apple-darwin22.0) libcurl/7.88.1 (SecureTransport) LibreSSL/3.3.6 zlib/1.2.11 nghttp2/1.51.0 41 | Release-Date: 2023-02-20 42 | Protocols: dict file ftp ftps gopher gophers http https imap imaps ldap ldaps mqtt pop3 pop3s rtsp smb smbs smtp smtps telnet tftp 43 | Features: alt-svc AsynchDNS GSS-API HSTS HTTP2 HTTPS-proxy IPv6 Kerberos Largefile libz MultiSSL NTLM NTLM_WB SPNEGO SSL threadsafe UnixSockets`, 44 | }, 45 | {"dig", "9.10.6", "DiG 9.10.6"}, 46 | {"docker", "20.10.21", "version 20.10.21, build baeda1f"}, 47 | {"fpp", "0.9.2", "fpp version 0.9.2"}, 48 | {"fzf", "0.53.0", "0.53.0 (c4a9ccd)"}, 49 | {"gcc", "14.0.3", "clang version 14.0.3 (clang-1403.0.22.14.1)"}, 50 | {"grep", "3.4", `grep (GNU grep) 3.4 51 | Copyright (C) 2020 Free Software Foundation, Inc. 52 | License GPLv3+: GNU GPL version 3 or later . 53 | This is free software: you are free to change and redistribute it. 54 | There is NO WARRANTY, to the extent permitted by law. 55 | 56 | Written by Mike Haertel and others; see 57 | .`}, 58 | {"grep", "2.6.0", "grep (BSD grep, GNU compatible) 2.6.0-FreeBSD"}, 59 | {"gh", "2.30.0", "gh version 2.30.0 (2023-05-30)"}, 60 | {"go", "1.20.4", "go version go1.20.4 darwin/amd64"}, 61 | {"golangci-lint", "2.0.2", "golangci-lint has version 2.0.2 built with go1.24.1 from 2b224c2c on 2025-03-25T21:36:18Z"}, 62 | {"gopls", "0.15.2", "golang.org/x/tools/gopls v0.15.2"}, 63 | {"hugo", "0.136.5", "hugo v0.136.5+extended darwin/arm64 BuildDate=2024-10-24T12:26:27Z VendorInfo=brew"}, 64 | {"jq", "1.6", "jq-1.6"}, 65 | {"less", "633", "less 633 (PCRE2 regular expressions)"}, 66 | {"lua", "5.4.6", "Lua 5.4.6 Copyright (C) 1994-2023 Lua.org, PUC-Rio"}, 67 | {"make", "3.81", `GNU Make 3.81 68 | Copyright (C) 2006 Free Software Foundation, Inc. 69 | This is free software; see the source for copying conditions. 70 | There is NO warranty; not even for MERCHANTABILITY or FITNESS FOR A 71 | PARTICULAR PURPOSE. 72 | 73 | This program built for i386-apple-darwin11.3.0`}, 74 | {"md5sum", "9.3", "md5sum (GNU coreutils) 9.3"}, 75 | {"nvim", "v0.10.0-dev-2663+gc1c6c1ee1", `NVIM v0.10.0-dev-2663+gc1c6c1ee1 76 | Build type: RelWithDebInfo 77 | LuaJIT 2.1.1710088188 78 | Run "nvim -V1 -v" for more info`}, 79 | {"node", "v20.2.0", "v20.2.0"}, 80 | {"npx", "9.6.6", "9.6.6"}, 81 | {"ocaml", "5.1.0", "The OCaml toplevel, version 5.1.0"}, 82 | {"opam", "2.1.5", "2.1.5"}, 83 | {"openssl", "3.3.6", "LibreSSL 3.3.6"}, 84 | {"openssl", "1.1.1f", "OpenSSL 1.1.1f 31 Mar 2020"}, 85 | { 86 | "perl", "v5.36.0", 87 | `This is perl 5, version 36, subversion 0 (v5.36.0) built for darwin-2level`, 88 | }, 89 | {"oh-my-posh", "16.9.1", "16.9.1"}, 90 | // the trailing newline is in perltidy's output, so this test should preserve it 91 | {"perltidy", "v20230701", `This is perltidy, v20230701 92 | 93 | Copyright 2000-2023, Steve Hancock 94 | 95 | Perltidy is free software and may be copied under the terms of the GNU 96 | General Public License, which is included in the distribution files. 97 | 98 | Complete documentation for perltidy can be found using 'man perltidy' 99 | or on the internet at http://perltidy.sourceforge.net. 100 | `}, 101 | {"perldoc", "v3.2801", "v3.2801, under perl v5.040000 for darwin"}, 102 | { 103 | "pihole", "v5.17.1", 104 | ` Pi-hole version is v5.17.1 (Latest: v5.17.1) 105 | AdminLTE version is v5.20.1 (Latest: v5.20.1) 106 | FTL version is v5.23 (Latest: v5.23)`, 107 | }, 108 | {"plenv", "2.3.1-8-gd908472", "plenv 2.3.1-8-gd908472"}, 109 | {"python", "3.11.3", "Python 3.11.3"}, 110 | {"python3", "3.11.3", "Python 3.11.3"}, 111 | {"ripgrep", "13.0.0", "ripgrep 13.0.0"}, 112 | {"rustc", "1.73.0", "rustc 1.73.0 (cc66ad468 2023-10-03)"}, 113 | {"screen", "4.08.00", "Screen version 4.08.00 (GNU) 05-Feb-20"}, 114 | {"sh", "3.2.57", `GNU bash, version 3.2.57(1)-release (x86_64-apple-darwin22) 115 | Copyright (C) 2007 Free Software Foundation, Inc. 116 | `}, 117 | {"sqlite3", "3.46.0", "3.46.0 2024-05-23 13:25:27 96c92aba00c8375bc32fafcdf12429c58bd8aabfcadab6683e35bbb9cdebf19e (64-bit)"}, 118 | {"tar", "3.5.3", "bsdtar 3.5.3 - libarchive 3.5.3 zlib/1.2.11 liblzma/5.0.5 bz2lib/1.0.8"}, 119 | {"tcsh", "6.21.00", "tcsh 6.21.00 (Astron) 2019-05-08 (x86_64-apple-darwin) options wide,nls,dl,bye,al,kan,sm,rh,color,filec"}, 120 | {"trurl", "0.6", "trurl version 0.6 libcurl/7.88.1 [built-with 7.87.0]"}, 121 | {"tmux", "3.3a", "tmux 3.3a"}, 122 | { 123 | "tree", "v2.1.0", 124 | `tree v2.1.0 © 1996 - 2022 by Steve Baker, Thomas Moore, Francesc Rocher, Florian Sesser, Kyosuke Tokoro`, 125 | }, 126 | {"typos", "1.22.7", "typos-cli 1.22.7"}, 127 | {"ubi", "0.0.24", "ubi 0.0.24"}, 128 | { 129 | "unzip", "6.00", 130 | `caution: both -n and -o specified; ignoring -o 131 | UnZip 6.00 of 20 April 2009, by Info-ZIP. Maintained by C. Spieler. Send 132 | bug reports using http://www.info-zip.org/zip-bug.html; see README for details.`, 133 | }, 134 | { 135 | "vim", "9.0", 136 | "VIM - Vi IMproved 9.0 (2022 Jun 28, compiled Apr 15 2023 04:26:46)", 137 | }, 138 | {"zsh", "5.9", "zsh 5.9 (x86_64-apple-darwin22.0)"}, 139 | } 140 | 141 | for _, test := range tests { 142 | assert.Equal(t, test[1], parser.CLIVersion( 143 | &ctx, test[0], test[2], 144 | )) 145 | } 146 | 147 | { 148 | ctx := &types.Context{ 149 | Context: context.Background(), 150 | Debug: true, 151 | } 152 | o, err := (parser.CLIOutput(ctx, "../testdata/bin/bad-version")) 153 | assert.NoError(t, err) 154 | assert.Equal(t, "X3v", o) 155 | got := parser.CLIVersion(ctx, "../testdata/bin/bad-version", o) 156 | assert.Equal(t, "X3v", got) 157 | } 158 | } 159 | 160 | func TestCLIOutput(t *testing.T) { 161 | t.Parallel() 162 | ctx := &types.Context{ 163 | Context: context.Background(), 164 | Debug: true, 165 | } 166 | { 167 | o, err := (parser.CLIOutput(ctx, ssh)) 168 | assert.NoError(t, err) 169 | assert.NotEmpty(t, o) 170 | } 171 | { 172 | ctx := &types.Context{ 173 | Context: context.Background(), 174 | } 175 | o, err := (parser.CLIOutput(ctx, tmux)) 176 | assert.NoError(t, err) 177 | assert.NotEmpty(t, o) 178 | } 179 | 180 | { 181 | ctx := &types.Context{ 182 | Context: context.Background(), 183 | } 184 | o, err := (parser.CLIOutput(ctx, "tmuxxx")) 185 | assert.Error(t, err) 186 | assert.Empty(t, o) 187 | } 188 | 189 | { 190 | ctx := &types.Context{ 191 | Context: context.Background(), 192 | Debug: true, 193 | } 194 | o, err := (parser.CLIOutput(ctx, "../testdata/bin/bad-version")) 195 | assert.NoError(t, err) 196 | assert.Equal(t, "X3v", o) 197 | } 198 | } 199 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/alecthomas/assert/v2 v2.1.0 h1:tbredtNcQnoSd3QBhQWI7QZ3XHOVkw1Moklp2ojoH/0= 2 | github.com/alecthomas/assert/v2 v2.1.0/go.mod h1:b/+1DI2Q6NckYi+3mXyH3wFb8qG37K/DuK80n7WefXA= 3 | github.com/alecthomas/kong v0.7.1 h1:azoTh0IOfwlAX3qN9sHWTxACE2oV8Bg2gAwBsMwDQY4= 4 | github.com/alecthomas/kong v0.7.1/go.mod h1:n1iCIO2xS46oE8ZfYCNDqdR0b0wZNrXAIAqro/2132U= 5 | github.com/alecthomas/repr v0.1.0 h1:ENn2e1+J3k09gyj2shc0dHr/yjaWSHRlrJ4DPMevDqE= 6 | github.com/alecthomas/repr v0.1.0/go.mod h1:2kn6fqh/zIyPLmm3ugklbEi5hg5wS435eygvNfaDQL8= 7 | github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= 8 | github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= 9 | github.com/aymanbagabas/go-udiff v0.2.0 h1:TK0fH4MteXUDspT88n8CKzvK0X9O2xu9yQjWpi6yML8= 10 | github.com/aymanbagabas/go-udiff v0.2.0/go.mod h1:RE4Ex0qsGkTAJoQdQQCA0uG+nAzJO/pI/QwceO5fgrA= 11 | github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc h1:4pZI35227imm7yK2bGPcfpFEmuY1gc2YSTShr4iJBfs= 12 | github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc/go.mod h1:X4/0JoqgTIPSFcRA/P6INZzIuyqdFY5rm8tb41s9okk= 13 | github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY= 14 | github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30= 15 | github.com/charmbracelet/x/ansi v0.8.0 h1:9GTq3xq9caJW8ZrBTe0LIe2fvfLR/bYXKTx2llXn7xE= 16 | github.com/charmbracelet/x/ansi v0.8.0/go.mod h1:wdYl/ONOLHLIVmQaxbIYEC/cRKOQyjTkowiI4blgS9Q= 17 | github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd h1:vy0GVL4jeHEwG5YOXDmi86oYw2yuYUGqz6a8sLwg0X8= 18 | github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd/go.mod h1:xe0nKWGd3eJgtqZRaN9RjMtK7xUYchjzPr7q6kcvCCs= 19 | github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91 h1:payRxjMjKgx2PaCWLZ4p3ro9y97+TVLZNaRZgJwSVDQ= 20 | github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91/go.mod h1:wDlXFlCrmJ8J+swcL/MnGUuYnqgQdW9rhSD61oNMb6U= 21 | github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ= 22 | github.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg= 23 | github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 24 | github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= 25 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 26 | github.com/distatus/battery v0.11.0 h1:KJk89gz90Iq/wJtbjjM9yUzBXV+ASV/EG2WOOL7N8lc= 27 | github.com/distatus/battery v0.11.0/go.mod h1:KmVkE8A8hpIX4T78QRdMktYpEp35QfOL8A8dwZBxq2k= 28 | github.com/go-ole/go-ole v1.2.4/go.mod h1:XCwSNxSkXRo4vlyPy93sltvi/qJq0jqQhjqQNIwKuxM= 29 | github.com/go-ole/go-ole v1.2.6 h1:/Fpf6oFPoeFik9ty7siob0G6Ke8QvQEuVcuChpwXzpY= 30 | github.com/go-ole/go-ole v1.2.6/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0= 31 | github.com/hashicorp/errwrap v1.0.0 h1:hLrqtEDnRye3+sgx6z4qVLNuviH3MR5aQ0ykNJa/UYA= 32 | github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= 33 | github.com/hashicorp/go-multierror v1.0.0 h1:iVjPR7a6H0tWELX5NxNe7bYopibicUzc7uPribsnS6o= 34 | github.com/hashicorp/go-multierror v1.0.0/go.mod h1:dHtQlpGsu+cZNNAkkCN/P3hoUDHhCYQXV3UM06sGGrk= 35 | github.com/hashicorp/go-version v1.6.0 h1:feTTfFNnjP967rlCxM/I9g701jU+RN74YKx2mOkIeek= 36 | github.com/hashicorp/go-version v1.6.0/go.mod h1:fltr4n8CU8Ke44wwGCBoEymUuxUHl09ZGVZPK5anwXA= 37 | github.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUqJM= 38 | github.com/hexops/gotextdiff v1.0.3/go.mod h1:pSWU5MAI3yDq+fZBTazCSJysOMbxWL1BSow5/V2vxeg= 39 | github.com/itchyny/volume-go v0.2.2 h1:v+FX58TV+g/IelerseqMO1LmdRoIuSS2uB26Ggljzx0= 40 | github.com/itchyny/volume-go v0.2.2/go.mod h1:0JOgisElMS/72B2DI4ha8CH2JXPUPTbe1agjk8jTU3s= 41 | github.com/jessevdk/go-flags v1.4.0/go.mod h1:4FA24M0QyGHXBuZZK/XkWh8h0e1EYbRYJSGM75WSRxI= 42 | github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY= 43 | github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= 44 | github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= 45 | github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= 46 | github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc= 47 | github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= 48 | github.com/moutend/go-wca v0.2.0 h1:AEzY6ltC5zPCldKyMYdyXv3TaLqwxSW1TIradqNqRpU= 49 | github.com/moutend/go-wca v0.2.0/go.mod h1:L/ka++dPvkHYz0UuQ/PIQ3aTuecoXOIM1RSAesh6RYU= 50 | github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc= 51 | github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk= 52 | github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= 53 | github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= 54 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 55 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 56 | github.com/posener/complete v1.2.3 h1:NP0eAhjcjImqslEwo/1hq7gpajME0fTLTezBKDqfXqo= 57 | github.com/posener/complete v1.2.3/go.mod h1:WZIdtGGp+qx0sLrYKtIRAruyNpv6hFCicSgv7Sy7s/s= 58 | github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= 59 | github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= 60 | github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= 61 | github.com/riywo/loginshell v0.0.0-20200815045211-7d26008be1ab h1:ZjX6I48eZSFetPb41dHudEyVr5v953N15TsNZXlkcWY= 62 | github.com/riywo/loginshell v0.0.0-20200815045211-7d26008be1ab/go.mod h1:/PfPXh0EntGc3QAAyUaviy4S9tzy4Zp0e2ilq4voC6E= 63 | github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= 64 | github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= 65 | github.com/stretchr/testify v1.8.3 h1:RP3t2pwF7cMEbC1dqtB6poj3niw/9gnV4Cjg5oW5gtY= 66 | github.com/stretchr/testify v1.8.3/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= 67 | github.com/willabides/kongplete v0.3.0 h1:8dJZ0r2a2YnSdYCQk9TjQDKzLrj1zUvIOPIG3bOV75c= 68 | github.com/willabides/kongplete v0.3.0/go.mod h1:VPdrG6LY+tP0LMkSBuTgIQ8c6+P8wvIDHVJzDdDh9Fw= 69 | github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= 70 | github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= 71 | golang.org/x/exp v0.0.0-20220909182711-5c715a9e8561 h1:MDc5xs78ZrZr3HMQugiXOAkSZtfTpbJLDr/lwfgO53E= 72 | golang.org/x/exp v0.0.0-20220909182711-5c715a9e8561/go.mod h1:cyybsKvd6eL0RnXn6p/Grxp8F5bW7iYuBgsNCOHpMYE= 73 | golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 74 | golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 75 | golang.org/x/sys v0.30.0 h1:QjkSwP/36a20jFYWkSue1YwXzLmsV5Gfq7Eiy72C1uc= 76 | golang.org/x/sys v0.30.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= 77 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= 78 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 79 | gopkg.in/ini.v1 v1.67.0 h1:Dgnx+6+nfE+IfzjUEISNeydPJh9AXNNsWbGP9KzCsOA= 80 | gopkg.in/ini.v1 v1.67.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k= 81 | gopkg.in/yaml.v1 v1.0.0-20140924161607-9f9df34309c0/go.mod h1:WDnlLJ4WF5VGsH/HVa3CI79GS0ol3YnhVnKP89i0kNg= 82 | gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= 83 | gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= 84 | gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 85 | howett.net/plist v1.0.1 h1:37GdZ8tP09Q35o9ych3ehygcsL+HqKSwzctveSlarvM= 86 | howett.net/plist v1.0.1/go.mod h1:lqaXoTrLY4hg8tnEzNru53gicrbv7rrk+2xJA/7hw9g= 87 | -------------------------------------------------------------------------------- /compare/compare.go: -------------------------------------------------------------------------------- 1 | // package compare compares versions 2 | package compare 3 | 4 | import ( 5 | "errors" 6 | "fmt" 7 | "log" 8 | "regexp" 9 | "slices" 10 | "strconv" 11 | "strings" 12 | 13 | "github.com/oalders/is/ops" 14 | "github.com/oalders/is/types" 15 | "github.com/oalders/is/version" 16 | ) 17 | 18 | type Number interface { 19 | int | float32 | float64 20 | } 21 | 22 | func IntegersOrFloats[T Number](ctx *types.Context, operator string, got, want T) (bool, error) { 23 | if ctx.Debug { 24 | log.Printf("Evaluating %v %s %v\n", got, operator, want) 25 | } 26 | 27 | switch operator { 28 | case ops.Eq: 29 | return got == want, nil 30 | case ops.Ne: 31 | return got != want, nil 32 | case ops.Gt: 33 | return got > want, nil 34 | case ops.Gte: 35 | return got >= want, nil 36 | case ops.Lt: 37 | return got < want, nil 38 | case ops.Lte: 39 | return got <= want, nil 40 | default: 41 | return false, fmt.Errorf("unsupported operator: %s", operator) 42 | } 43 | } 44 | 45 | func Floats(ctx *types.Context, operator, g, w string) (bool, error) { //nolint:varnamelen 46 | if operator == ops.In { 47 | wantList, err := want2List(w) 48 | if err != nil { 49 | return false, err 50 | } 51 | for _, v := range wantList { 52 | success, err := Floats(ctx, ops.Eq, g, v) 53 | if err != nil { 54 | return false, err 55 | } 56 | if success { 57 | return true, nil 58 | } 59 | } 60 | return false, nil 61 | } 62 | got, err := strconv.ParseFloat(g, 32) 63 | if err != nil { 64 | return false, fmt.Errorf("wanted result must be a float: %w", err) 65 | } 66 | want, err := strconv.ParseFloat(w, 32) 67 | if err != nil { 68 | return false, fmt.Errorf("command output is not a float: %w", err) 69 | } 70 | 71 | if ctx.Debug { 72 | log.Printf("compare floats %f %s %f", got, operator, want) 73 | } 74 | success, err := IntegersOrFloats(ctx, operator, got, want) 75 | if err != nil { 76 | return false, err 77 | } 78 | return success, nil 79 | } 80 | 81 | func Integers(ctx *types.Context, operator, g, w string) (bool, error) { //nolint:varnamelen 82 | if operator == ops.In { 83 | wantList, err := want2List(w) 84 | if err != nil { 85 | return false, err 86 | } 87 | 88 | for _, v := range wantList { 89 | success, err := Integers(ctx, ops.Eq, g, v) 90 | if err != nil { 91 | return false, err 92 | } 93 | if success { 94 | return true, nil 95 | } 96 | } 97 | return false, nil 98 | } 99 | got, err := strconv.Atoi(g) 100 | if err != nil { 101 | return false, fmt.Errorf("wanted result must be an integer: %w", err) 102 | } 103 | want, err := strconv.Atoi(w) 104 | if err != nil { 105 | return false, fmt.Errorf("command output is not an integer: %w", err) 106 | } 107 | 108 | if ctx.Debug { 109 | log.Printf("compare integers %d %s %d", got, operator, want) 110 | } 111 | success, err := IntegersOrFloats(ctx, operator, got, want) 112 | if err != nil { 113 | return false, err 114 | } 115 | return success, nil 116 | } 117 | 118 | func VersionSegment( 119 | ctx *types.Context, 120 | operator, gotStr, wantStr string, 121 | segment uint, 122 | ) (bool, error) { 123 | if operator == ops.In { 124 | wantList, err := want2List(wantStr) 125 | if err != nil { 126 | return false, err 127 | } 128 | for _, v := range wantList { 129 | success, err := VersionSegment(ctx, ops.Eq, gotStr, v, segment) 130 | if err != nil { 131 | return false, err 132 | } 133 | if success { 134 | return true, nil 135 | } 136 | } 137 | return false, nil 138 | } 139 | got, err := version.NewVersion(gotStr) 140 | if err != nil { 141 | return false, fmt.Errorf("parse version from output: %w", err) 142 | } 143 | 144 | segments := got.Segments() 145 | gotSegment := segments[segment] 146 | 147 | switch operator { 148 | case ops.Like, ops.Unlike: 149 | return Strings(ctx, operator, fmt.Sprint(gotSegment), wantStr) 150 | } 151 | return Integers(ctx, operator, fmt.Sprint(gotSegment), wantStr) 152 | } 153 | 154 | func Versions( //nolint:cyclop 155 | ctx *types.Context, 156 | operator, gotStr, wantStr string, 157 | ) (bool, error) { 158 | maybeDebug(ctx, "versions", operator, gotStr, wantStr) 159 | 160 | switch operator { 161 | case ops.In: 162 | wantList, err := want2List(wantStr) 163 | if err != nil { 164 | return false, err 165 | } 166 | for _, v := range wantList { 167 | success, err := Versions(ctx, ops.Eq, gotStr, v) 168 | if err != nil { 169 | return false, err 170 | } 171 | if success { 172 | return true, nil 173 | } 174 | } 175 | return false, nil 176 | case ops.Like, ops.Unlike: 177 | return Strings(ctx, operator, gotStr, wantStr) 178 | } 179 | 180 | got, err := version.NewVersion(gotStr) 181 | if err != nil { 182 | return false, err 183 | } 184 | want, err := version.NewVersion(wantStr) 185 | if err != nil { 186 | return false, err 187 | } 188 | 189 | switch operator { 190 | case ops.Eq: 191 | return got.Equal(want), nil 192 | case ops.Ne: 193 | return got.Compare(want) != 0, nil 194 | case ops.Lt: 195 | return got.LessThan(want), nil 196 | case ops.Lte: 197 | return got.Compare(want) <= 0, nil 198 | case ops.Gt: 199 | return got.GreaterThan(want), nil 200 | case ops.Gte: 201 | return got.Compare(want) >= 0, nil 202 | default: 203 | return false, fmt.Errorf("unsupported operator: %s", operator) 204 | } 205 | } 206 | 207 | //nolint:cyclop 208 | func Strings(ctx *types.Context, operator, got, want string) (bool, error) { 209 | maybeDebug(ctx, "strings", operator, got, want) 210 | 211 | switch operator { 212 | case ops.Eq: 213 | return got == want, nil 214 | case ops.In: 215 | wantList, err := want2List(want) 216 | if err != nil { 217 | return false, err 218 | } 219 | return slices.Contains(wantList, got), nil 220 | case ops.Ne: 221 | matched, err := regexp.MatchString(want, got) 222 | if err != nil { 223 | return false, fmt.Errorf(`compare strings "%s" %s "%s"`, got, operator, want) 224 | } 225 | ctx.Success = matched 226 | if operator == ops.Unlike { 227 | ctx.Success = !matched 228 | } 229 | return got != want, nil 230 | case ops.Like: 231 | success, err := regexp.MatchString(want, got) 232 | if err != nil { 233 | return false, fmt.Errorf(`compare strings "%s" %s "%s"`, got, operator, want) 234 | } 235 | return success, nil 236 | case ops.Unlike: 237 | success, err := regexp.MatchString(want, got) 238 | if err != nil { 239 | return false, fmt.Errorf(`compare strings "%s" %s "%s"`, got, operator, want) 240 | } 241 | return !success, nil 242 | default: 243 | return false, fmt.Errorf("unsupported operator: %s", operator) 244 | } 245 | } 246 | 247 | //nolint:cyclop 248 | func Optimistic(ctx *types.Context, operator, got, want string) bool { 249 | stringy := []string{ops.Eq, ops.In, ops.Ne, ops.Like, ops.Unlike} 250 | reg := []string{ops.Like, ops.Unlike} 251 | if slices.Contains(stringy, operator) { 252 | success, err := Strings(ctx, operator, got, want) 253 | if err != nil && ctx.Debug { 254 | log.Printf("cannot compare strings: %s", err) 255 | } 256 | if success || slices.Contains(reg, operator) { 257 | return success 258 | } 259 | } 260 | 261 | // We are being optimistic here and we can't know if the intention was a 262 | // string or a numeric comparison, so we'll suppress the error message 263 | // unless debugging is enabled. 264 | 265 | { 266 | success, err := Integers(ctx, operator, got, want) 267 | if err != nil && ctx.Debug { 268 | log.Printf("cannot compare integers: %s", err) 269 | } 270 | if success { 271 | return true 272 | } 273 | } 274 | 275 | { 276 | success, err := Floats(ctx, operator, got, want) 277 | if err != nil && ctx.Debug { 278 | log.Printf("cannot compare floats: %s", err) 279 | } 280 | if success { 281 | return true 282 | } 283 | } 284 | 285 | success, err := Versions(ctx, operator, got, want) 286 | if err != nil && ctx.Debug { 287 | log.Printf("cannot compare versions: %s", err) 288 | } 289 | return success 290 | } 291 | 292 | func want2List(want string) ([]string, error) { 293 | wantList := strings.Split(want, ",") 294 | for i := range wantList { 295 | wantList[i] = strings.TrimSpace(wantList[i]) 296 | } 297 | if len(wantList) > 100 { 298 | return []string{}, errors.New("\"in\" takes a maximum of 100 arguments") 299 | } 300 | return wantList, nil 301 | } 302 | 303 | func maybeDebug(ctx *types.Context, comparisonType, operator, got, want string) { 304 | if !ctx.Debug { 305 | return 306 | } 307 | 308 | log.Printf(`compare %s: "%s" %s "%s"\n`, comparisonType, got, operator, want) 309 | } 310 | -------------------------------------------------------------------------------- /api.go: -------------------------------------------------------------------------------- 1 | // package main contains the api for the CLI 2 | package main 3 | 4 | import ( 5 | "fmt" 6 | ) 7 | 8 | type AgeCmp struct { 9 | Name string `arg:"" required:"" help:"[name of command or path to command]"` 10 | Op string `arg:"" required:"" enum:"gt,lt" help:"[gt|lt]"` 11 | Val string `arg:"" required:""` 12 | Unit string `arg:"" required:"" enum:"s,second,seconds,m,minute,minutes,h,hour,hours,d,day,days"` 13 | } 14 | 15 | //nolint:lll,govet,nolintlint 16 | type OutputCmp struct { 17 | Stream string `arg:"" required:"" enum:"stdout,stderr,combined" help:"[output stream to capture: (stdout|stderr|combined)]"` 18 | Command string `arg:"" required:"" help:"[name of command or path to command plus any arguments e.g. \"uname -a\"]"` 19 | Op string `arg:"" required:"" enum:"eq,ne,gt,gte,in,lt,lte,like,unlike" help:"[eq|ne|gt|gte|in|like|lt|lte|unlike]"` 20 | Val string `arg:"" required:""` 21 | Arg []string `short:"a" optional:"" help:"--arg=\"-V\" --arg foo"` 22 | Compare string `default:"optimistic" enum:"float,integer,string,version,optimistic" help:"[float|integer|string|version|optimistic]"` 23 | } 24 | 25 | //nolint:lll,govet,nolintlint 26 | type VersionCmp struct { 27 | Name string `arg:"" required:"" help:"[name of command or path to command]"` 28 | Op string `arg:"" required:"" enum:"eq,ne,gt,gte,in,lt,lte,like,unlike" help:"[eq|ne|gt|gte|in|like|lt|lte|unlike]"` 29 | Val string `arg:"" required:""` 30 | Major bool `xor:"Major,Minor,Patch" help:"Only match on the major version (e.g. major.minor.patch)"` 31 | Minor bool `xor:"Major,Minor,Patch" help:"Only match on the minor version (e.g. major.minor.patch)"` 32 | Patch bool `xor:"Major,Minor,Patch" help:"Only match on the patch version (e.g. major.minor.patch)"` 33 | } 34 | 35 | type ArchCmd struct { 36 | Op string `arg:"" required:"" enum:"eq,ne,in,like,unlike" help:"[eq|ne|in|like|unlike]"` 37 | Val string `arg:"" required:""` 38 | } 39 | 40 | // CLICmd type is configuration for CLI checks. 41 | // 42 | //nolint:lll,govet,nolintlint 43 | type CLICmd struct { 44 | Version VersionCmp `cmd:"" help:"Check version of command. e.g. \"is cli version tmux gte 3\""` 45 | Age AgeCmp `cmd:"" help:"Check last modified time of cli (2h, 4d). e.g. \"is cli age tmux gt 1 d\""` 46 | Output OutputCmp `cmd:"" help:"Check output of a command. e.g. \"is cli output stdout \"uname -a\" like \"Kernel Version 22.5\""` 47 | } 48 | 49 | // FSOCmd type is configuration for FSO checks. 50 | // 51 | //nolint:lll,govet,nolintlint 52 | type FSOCmd struct { 53 | Age AgeCmp `cmd:"" help:"Check age (last modified time) of an fso (2h, 4d). e.g. \"is fso age /tmp/log.txt gt 1 d\""` 54 | } 55 | 56 | // OSCmd type is configuration for OS level checks. 57 | // 58 | //nolint:lll,govet,nolintlint 59 | type OSCmd struct { 60 | Attr string `arg:"" required:"" name:"attribute" help:"[id|id-like|pretty-name|name|version|version-codename]"` 61 | Op string `arg:"" required:"" enum:"eq,ne,gt,gte,in,lt,lte,like,unlike" help:"[eq|ne|gt|gte|in|like|lt|lte|unlike]"` 62 | Val string `arg:"" required:""` 63 | Major bool `xor:"Major,Minor,Patch" help:"Only match on the major OS version (e.g. major.minor.patch)"` 64 | Minor bool `xor:"Major,Minor,Patch" help:"Only match on the minor OS version (e.g. major.minor.patch)"` 65 | Patch bool `xor:"Major,Minor,Patch" help:"Only match on the patch OS version (e.g. major.minor.patch)"` 66 | } 67 | 68 | // Battery type is configuration for battery information. 69 | // 70 | //nolint:lll,govet,nolintlint 71 | type Battery struct { 72 | Attr string `arg:"" required:"" name:"attribute" enum:"charge-rate,count,current-capacity,current-charge,design-capacity,design-voltage,last-full-capacity,state,voltage" help:"[charge-rate|count|current-capacity|current-charge|design-capacity|design-voltage|last-full-capacity|state|voltage]"` 73 | Nth int `optional:"" default:"1" help:"Specify which battery to use (1 for the first battery)"` 74 | Round bool `help:"Round float values to the nearest integer"` 75 | } 76 | 77 | type Audio struct { 78 | Attr string `arg:"" required:"" enum:"level,muted" help:"[level|muted]"` 79 | } 80 | 81 | //nolint:lll,govet,nolintlint 82 | type AudioCmd struct { 83 | Audio 84 | Op string `arg:"" required:"" default:"eq" enum:"eq,ne,gt,gte,in,lt,lte,like,unlike" help:"[eq|ne|gt|gte|in|like|lt|lte|unlike]"` 85 | Val string `arg:"" optional:"" help:"Value to compare against"` 86 | } 87 | 88 | func (r *AudioCmd) Validate() error { 89 | // For "muted" attribute without a value, we're just checking if audio is muted 90 | if r.Attr == "muted" && r.Val == "" { 91 | return nil 92 | } 93 | 94 | // For other attributes or when comparing muted to a specific value 95 | if r.Val == "" && r.Op != "eq" && r.Op != "ne" { 96 | return fmt.Errorf("missing required argument: val") 97 | } 98 | 99 | return nil 100 | } 101 | 102 | type Summary struct { 103 | Attr string `arg:"" required:"" name:"attribute" enum:"battery,os,var" help:"[battery|os|var]"` 104 | Nth int `optional:"" default:"1" help:"Specify which battery to use (1 for the first battery)"` 105 | JSON bool `xor:"format" help:"print summary as JSON"` 106 | MD bool `xor:"format" help:"print summary as a Markdown table"` 107 | } 108 | 109 | //nolint:lll,govet,nolintlint 110 | type BatteryCmd struct { 111 | Battery 112 | Op string `arg:"" required:"" enum:"eq,ne,gt,gte,in,lt,lte,like,unlike" help:"[eq|ne|gt|gte|in|like|lt|lte|unlike]"` 113 | Val string `arg:"" required:""` 114 | } 115 | 116 | // UserCmd type is configuration for user level checks. 117 | // 118 | //nolint:lll,govet,nolintlint 119 | type UserCmd struct { 120 | Sudoer string `arg:"" required:"" default:"sudoer" enum:"sudoer" help:"is current user a passwordless sudoer. e.g. \"is user sudoer\""` 121 | } 122 | 123 | // VarCmd type is configuration for environment variable checks. 124 | // 125 | //nolint:lll,govet,nolintlint 126 | type VarCmd struct { 127 | Name string `arg:"" required:""` 128 | Op string `arg:"" required:"" enum:"set,unset,true,false,eq,ne,gt,gte,in,lt,lte,like,unlike" help:"[set|unset|true|false|eq|ne|gt|gte|in|like|lt|lte|unlike]"` 129 | Val string `arg:"" optional:""` 130 | Compare string `default:"optimistic" enum:"float,integer,string,version,optimistic" help:"[float|integer|string|version|optimistic]"` 131 | } 132 | 133 | // Validate allows for some commands to forego requiring an additional argument. 134 | func (r *VarCmd) Validate() error { 135 | switch r.Op { 136 | case "set", "unset", "true", "false", "eq", "ne": 137 | return nil 138 | default: 139 | if r.Val == "" { 140 | return fmt.Errorf("missing required argument: val") 141 | } 142 | } 143 | return nil 144 | } 145 | 146 | type Version struct { 147 | Major bool `xor:"Major,Minor,Patch" help:"Only print the major version (e.g. major.minor.patch)"` 148 | Minor bool `xor:"Major,Minor,Patch" help:"Only print the minor version (e.g. major.minor.patch)"` 149 | Patch bool `xor:"Major,Minor,Patch" help:"Only print the patch version (e.g. major.minor.patch)"` 150 | } 151 | 152 | type KnownCLI struct { 153 | Attr string `arg:"" name:"attribute" required:"" enum:"version"` 154 | Name string `arg:"" required:""` 155 | Version 156 | } 157 | 158 | type KnownVar struct { 159 | Name string `arg:"" required:""` 160 | JSON bool `help:"Print output in JSON format"` 161 | } 162 | 163 | type KnownOS struct { 164 | //nolint:lll 165 | Attr string `arg:"" required:"" name:"attribute" help:"[id|id-like|pretty-name|name|version|version-codename]"` 166 | Version 167 | } 168 | 169 | // KnownCmd type is configuration for printing environment info. 170 | type KnownCmd struct { 171 | Arch struct { 172 | Attr string `arg:"" required:"" default:"arch" enum:"arch"` 173 | } `cmd:"" help:"Print arch without check. e.g. \"is known arch\""` 174 | OS KnownOS `cmd:"" help:"Print without check. e.g. \"is known os name\""` 175 | CLI KnownCLI `cmd:"" help:"Print without check. e.g. \"is known cli version git\""` 176 | Var KnownVar `cmd:"" help:"Print env var without a check. e.g. \"is known var PATH\""` 177 | Battery Battery `cmd:"" help:"Print battery information. e.g. \"is known battery state\""` 178 | Summary Summary `cmd:"" help:"summary of available data."` 179 | Audio Audio `cmd:"" help:"Print without check. e.g. \"is known audio level\""` 180 | } 181 | 182 | // ThereCmd is configuration for finding executables. 183 | type ThereCmd struct { 184 | Name string `arg:"" required:""` 185 | All bool `help:"Print all found binaries"` 186 | JSON bool `help:"Print output in JSON format"` 187 | Verbose bool `help:"Show binary versions"` 188 | } 189 | -------------------------------------------------------------------------------- /known_test.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "io" 7 | "os" 8 | "path/filepath" 9 | "runtime" 10 | "strings" 11 | "testing" 12 | 13 | "github.com/oalders/is/attr" 14 | "github.com/oalders/is/types" 15 | "github.com/stretchr/testify/assert" 16 | "github.com/stretchr/testify/require" 17 | ) 18 | 19 | //nolint:unparam 20 | func prependPath(path string) string { 21 | wd, err := os.Getwd() 22 | if err != nil { 23 | panic(err) 24 | } 25 | 26 | return filepath.Join(wd, path) + string(os.PathListSeparator) + os.Getenv("PATH") 27 | } 28 | 29 | //nolint:paralleltest,nolintlint 30 | func TestKnownCmd(t *testing.T) { 31 | t.Setenv("PATH", prependPath("testdata/bin")) 32 | 33 | const command = "semver" 34 | type testableOS struct { 35 | Attr string 36 | Error bool 37 | Success bool 38 | } 39 | 40 | osTests := []testableOS{ 41 | {attr.Name, false, true}, 42 | {attr.Version, false, true}, 43 | {"tmuxxx", false, false}, 44 | {"tmuxxx", false, false}, 45 | } 46 | 47 | if runtime.GOOS == "darwin" { 48 | osTests = append(osTests, testableOS{attr.Version, false, true}) 49 | } 50 | 51 | for _, test := range osTests { 52 | ctx := &types.Context{ 53 | Context: context.Background(), 54 | Debug: true, 55 | } 56 | cmd := KnownCmd{} 57 | cmd.OS.Attr = test.Attr 58 | err := cmd.Run(ctx) 59 | name := fmt.Sprintf("%s err: %t success: %t", test.Attr, test.Error, test.Success) 60 | if test.Error { 61 | assert.Error(t, err, name) 62 | } else { 63 | assert.NoError(t, err, name) 64 | } 65 | if test.Success { 66 | assert.True(t, ctx.Success, name) 67 | } else { 68 | assert.False(t, ctx.Success, name) 69 | } 70 | } 71 | 72 | type testableCLI struct { 73 | Cmd KnownCmd 74 | Error bool 75 | Success bool 76 | } 77 | cliTests := []testableCLI{ 78 | {KnownCmd{CLI: KnownCLI{ 79 | Attr: attr.Version, 80 | Name: "gitzzz", 81 | }}, false, false}, 82 | {KnownCmd{ 83 | CLI: KnownCLI{ 84 | Attr: attr.Version, 85 | Name: command, 86 | }, 87 | }, false, true}, 88 | {KnownCmd{ 89 | CLI: KnownCLI{ 90 | Attr: attr.Version, 91 | Name: command, 92 | Version: Version{Major: true}, 93 | }, 94 | }, false, true}, 95 | {KnownCmd{CLI: KnownCLI{Attr: attr.Version, Name: command, Version: Version{ 96 | Minor: true, 97 | }}}, false, true}, 98 | {KnownCmd{CLI: KnownCLI{Attr: attr.Version, Name: command, Version: Version{ 99 | Patch: true, 100 | }}}, false, true}, 101 | } 102 | 103 | for _, test := range cliTests { 104 | ctx := &types.Context{ 105 | Context: context.Background(), 106 | Debug: true, 107 | } 108 | err := test.Cmd.Run(ctx) 109 | 110 | switch test.Error { 111 | case true: 112 | assert.Error(t, err) 113 | default: 114 | assert.NoError(t, err) 115 | } 116 | 117 | switch test.Success { 118 | case true: 119 | assert.True(t, ctx.Success) 120 | default: 121 | assert.False(t, ctx.Success) 122 | } 123 | } 124 | 125 | { 126 | ctx := &types.Context{ 127 | Context: context.Background(), 128 | Debug: true, 129 | } 130 | cmd := KnownCmd{} 131 | cmd.Arch.Attr = "arch" 132 | err := cmd.Run(ctx) 133 | assert.NoError(t, err) 134 | assert.True(t, ctx.Success, "success") 135 | } 136 | } 137 | 138 | func Test_getEnv(t *testing.T) { 139 | t.Run("regular environment variable", func(t *testing.T) { 140 | ctx := &types.Context{ 141 | Context: context.Background(), 142 | } 143 | // Setup 144 | testVarName := "TEST_ENV_VAR" 145 | testValue := "test_value" 146 | t.Setenv(testVarName, testValue) 147 | 148 | // Test non-JSON retrieval 149 | value, err := getEnv(ctx, testVarName, false) 150 | require.True(t, ctx.Success) 151 | require.NoError(t, err) 152 | assert.Equal(t, testValue, value) 153 | }) 154 | 155 | t.Run("path environment variable as JSON", func(t *testing.T) { 156 | ctx := &types.Context{ 157 | Context: context.Background(), 158 | } 159 | // Setup 160 | pathValue := "/usr/bin:/usr/local/bin:/bin" 161 | t.Setenv(path, pathValue) 162 | 163 | // Test JSON retrieval 164 | value, err := getEnv(ctx, path, true) 165 | require.NoError(t, err) 166 | assert.Contains(t, value, "/usr/bin") 167 | assert.Contains(t, value, "/usr/local/bin") 168 | assert.Contains(t, value, "/bin") 169 | 170 | // Verify it's valid JSON 171 | assert.True(t, strings.HasPrefix(value, "[")) 172 | assert.True(t, strings.HasSuffix(value, "]")) 173 | }) 174 | 175 | t.Run("manpath environment variable as JSON", func(t *testing.T) { 176 | ctx := &types.Context{ 177 | Context: context.Background(), 178 | } 179 | // Setup 180 | manpathValue := "/usr/share/man:/usr/local/share/man" 181 | t.Setenv(manpath, manpathValue) 182 | 183 | // Test JSON retrieval 184 | value, err := getEnv(ctx, manpath, true) 185 | require.NoError(t, err) 186 | assert.Contains(t, value, "/usr/share/man") 187 | assert.Contains(t, value, "/usr/local/share/man") 188 | 189 | // Verify it's valid JSON 190 | assert.True(t, strings.HasPrefix(value, "[")) 191 | assert.True(t, strings.HasSuffix(value, "]")) 192 | }) 193 | 194 | t.Run("non-path variable as JSON returns empty array", func(t *testing.T) { 195 | ctx := &types.Context{ 196 | Context: context.Background(), 197 | } 198 | // Setup 199 | testVarName := "REGULAR_VAR" 200 | testValue := "something" 201 | t.Setenv(testVarName, testValue) 202 | 203 | // Test JSON retrieval for non-path/manpath variable 204 | value, err := getEnv(ctx, testVarName, true) 205 | require.NoError(t, err) 206 | assert.Equal(t, "[\n \"something\"\n]", strings.TrimSpace(value)) 207 | }) 208 | 209 | t.Run("non-existent variable", func(t *testing.T) { //nolint:paralleltest,nolintlint 210 | ctx := &types.Context{ 211 | Context: context.Background(), 212 | } 213 | // Test non-existent variable with non-JSON mode 214 | value, err := getEnv(ctx, "NON_EXISTENT_VAR", false) 215 | require.NoError(t, err) 216 | assert.Equal(t, "", value) 217 | 218 | // Test non-existent variable with JSON mode 219 | value, err = getEnv(ctx, "NON_EXISTENT_VAR", true) 220 | require.NoError(t, err) 221 | assert.Equal(t, "null", strings.TrimSpace(value)) 222 | require.False(t, ctx.Success) 223 | }) 224 | } 225 | 226 | func Test_envSummary(t *testing.T) { 227 | t.Run("tabular output", func(t *testing.T) { 228 | // Set up test environment 229 | t.Setenv("TEST_VAR", "test_value") 230 | t.Setenv("PATH", "/usr/bin:/usr/local/bin") 231 | 232 | // Create context and capture stdout 233 | ctx := &types.Context{ 234 | Context: context.Background(), 235 | } 236 | originalStdout := os.Stdout 237 | r, w, err := os.Pipe() //nolint:varnamelen 238 | require.NoError(t, err) 239 | os.Stdout = w 240 | 241 | // Create a channel to signal when writing is done 242 | done := make(chan error) 243 | go func() { 244 | summaryErr := envSummary(ctx, false, false) 245 | w.Close() // Close writer after function completes 246 | done <- summaryErr 247 | }() 248 | 249 | // Read output 250 | var output strings.Builder 251 | _, err = io.Copy(&output, r) 252 | require.NoError(t, err) 253 | 254 | // Wait for writing to complete and check error 255 | require.NoError(t, <-done) 256 | 257 | // Restore stdout 258 | os.Stdout = originalStdout 259 | 260 | // Basic validations 261 | assert.True(t, ctx.Success) 262 | assert.Contains(t, output.String(), "TEST_VAR") 263 | assert.Contains(t, output.String(), "test_value") 264 | assert.Contains(t, output.String(), "PATH") 265 | }) 266 | 267 | t.Run("JSON output", func(t *testing.T) { 268 | // Set up test environment 269 | t.Setenv("TEST_VAR", "test_value") 270 | t.Setenv("PATH", "/usr/bin:/usr/local/bin") 271 | 272 | // Create context and capture stdout 273 | ctx := &types.Context{ 274 | Context: context.Background(), 275 | } 276 | originalStdout := os.Stdout 277 | r, w, err := os.Pipe() //nolint:varnamelen 278 | require.NoError(t, err) 279 | os.Stdout = w 280 | 281 | // Create a channel to signal when writing is done 282 | done := make(chan error) 283 | go func() { 284 | summaryErr := envSummary(ctx, true, false) 285 | w.Close() // Close writer after function completes 286 | done <- summaryErr 287 | }() 288 | 289 | // Read output 290 | var output strings.Builder 291 | _, err = io.Copy(&output, r) 292 | require.NoError(t, err) 293 | 294 | // Wait for writing to complete and check error 295 | require.NoError(t, <-done) 296 | 297 | // Restore stdout 298 | os.Stdout = originalStdout 299 | 300 | // Basic validations 301 | assert.True(t, ctx.Success) 302 | assert.Contains(t, output.String(), "TEST_VAR") 303 | assert.Contains(t, output.String(), "test_value") 304 | assert.Contains(t, output.String(), "PATH") 305 | assert.Contains(t, output.String(), "/usr/bin") 306 | }) 307 | } 308 | 309 | func TestMarkdown(t *testing.T) { 310 | t.Parallel() 311 | 312 | tests := []struct { 313 | name string 314 | headers []string 315 | rows [][]string 316 | expected string 317 | }{ 318 | { 319 | name: "empty input", 320 | headers: []string{}, 321 | rows: [][]string{}, 322 | expected: "| |\n|\n", 323 | }, 324 | { 325 | name: "single header, no rows", 326 | headers: []string{"Header"}, 327 | rows: [][]string{}, 328 | expected: "| Header |\n" + 329 | "|---|\n", 330 | }, 331 | { 332 | name: "multiple headers, no rows", 333 | headers: []string{"Header1", "Header2", "Header3"}, 334 | rows: [][]string{}, 335 | expected: "| Header1 | Header2 | Header3 |\n" + 336 | "|---|---|---|\n", 337 | }, 338 | { 339 | name: "with data rows", 340 | headers: []string{"Name", "Value"}, 341 | rows: [][]string{ 342 | {"key1", "value1"}, 343 | {"key2", "value2"}, 344 | }, 345 | expected: "| Name | Value |\n" + 346 | "|---|---|\n" + 347 | "| key1 | value1 |\n" + 348 | "| key2 | value2 |\n", 349 | }, 350 | { 351 | name: "with newlines in data", 352 | headers: []string{"Name", "Value"}, 353 | rows: [][]string{ 354 | {"key1", "line1\nline2"}, 355 | {"key2", "value2"}, 356 | }, 357 | expected: "| Name | Value |\n" + 358 | "|---|---|\n" + 359 | "| key1 | line1
line2 |\n" + 360 | "| key2 | value2 |\n", 361 | }, 362 | } 363 | 364 | for _, tt := range tests { 365 | t.Run(tt.name, func(t *testing.T) { 366 | t.Parallel() 367 | result := markdown(tt.headers, tt.rows) 368 | assert.Equal(t, tt.expected, result) 369 | }) 370 | } 371 | } 372 | -------------------------------------------------------------------------------- /known.go: -------------------------------------------------------------------------------- 1 | // package main contains the logic for the "known" command 2 | package main 3 | 4 | import ( 5 | "encoding/json" 6 | "errors" 7 | "fmt" 8 | "log" 9 | "os" 10 | "regexp" 11 | "runtime" 12 | "sort" 13 | "strings" 14 | 15 | "github.com/charmbracelet/lipgloss" 16 | "github.com/charmbracelet/lipgloss/table" 17 | "github.com/charmbracelet/x/term" 18 | "github.com/oalders/is/attr" 19 | "github.com/oalders/is/audio" 20 | "github.com/oalders/is/battery" 21 | is_os "github.com/oalders/is/os" 22 | "github.com/oalders/is/parser" 23 | "github.com/oalders/is/types" 24 | "github.com/oalders/is/version" 25 | ) 26 | 27 | const ( 28 | manpath = "MANPATH" 29 | path = "PATH" 30 | xdgConfigDirs = "XDG_CONFIG_DIRS" 31 | xdgDataDirs = "XDG_DATA_DIRS" 32 | trueStr = "true" 33 | falseStr = "false" 34 | ) 35 | 36 | // Run "is known ...". 37 | // 38 | //nolint:cyclop 39 | func (r *KnownCmd) Run(ctx *types.Context) error { 40 | result := "" 41 | { 42 | var err error 43 | 44 | switch { 45 | case r.Summary.Attr != "": 46 | return summary(ctx, r.Summary.Attr, r.Summary.Nth, r.Summary.JSON, r.Summary.MD) 47 | case r.OS.Attr != "": 48 | result, err = is_os.Info(ctx, r.OS.Attr) 49 | case r.CLI.Attr != "": 50 | result, err = runCLI(ctx, r.CLI.Name) 51 | case r.Var.Name != "": 52 | result, err = getEnv(ctx, r.Var.Name, r.Var.JSON) 53 | case r.Audio.Attr != "": 54 | if r.Audio.Attr == "level" { //nolint:nestif 55 | level, levelErr := audio.Level() 56 | if levelErr != nil { 57 | return levelErr 58 | } 59 | result = fmt.Sprintf("%d", level) 60 | } else { 61 | muted, mutedErr := audio.IsMuted() 62 | if mutedErr != nil { 63 | return mutedErr 64 | } 65 | if muted { 66 | result = trueStr 67 | } else { 68 | result = falseStr 69 | } 70 | } 71 | case r.Battery.Attr != "": 72 | result, err = battery.GetAttrAsString( 73 | ctx, 74 | r.Battery.Attr, 75 | r.Battery.Round, 76 | r.Battery.Nth, 77 | ) 78 | case r.Arch.Attr != "": 79 | success(ctx, runtime.GOARCH) 80 | return nil 81 | } 82 | if err != nil { 83 | return err 84 | } 85 | } 86 | 87 | if result != "" { 88 | isVersion, segment, versionErr := isVersion(r) 89 | if versionErr != nil { 90 | return versionErr 91 | } 92 | 93 | if isVersion { 94 | got, versionErr := version.NewVersion(result) 95 | if versionErr != nil { 96 | return fmt.Errorf("parse version from output: %w", versionErr) 97 | } 98 | segments := got.Segments() 99 | result = fmt.Sprintf("%d", segments[segment]) 100 | } 101 | } 102 | 103 | if r.Var.Name == "" && result != "" { 104 | ctx.Success = true 105 | } 106 | 107 | //nolint:forbidigo 108 | fmt.Println(result) 109 | return nil 110 | } 111 | 112 | //nolint:cyclop 113 | func isVersion(r *KnownCmd) (bool, uint, error) { //nolint:varnamelen 114 | if r.OS.Attr == attr.Version || r.CLI.Attr == attr.Version { 115 | switch { 116 | case r.OS.Major || r.CLI.Major: 117 | return true, 0, nil 118 | case r.OS.Minor || r.CLI.Minor: 119 | return true, 1, nil 120 | case r.OS.Patch || r.CLI.Patch: 121 | return true, 2, nil 122 | } 123 | } 124 | if r.OS.Major || r.OS.Minor || r.OS.Patch || r.CLI.Major || r.CLI.Minor || r.CLI.Patch { 125 | return false, 0, errors.New("--major, --minor and --patch can only be used with version") 126 | } 127 | return false, 0, nil 128 | } 129 | 130 | func runCLI(ctx *types.Context, cliName string) (string, error) { 131 | result, err := parser.CLIOutput(ctx, cliName) 132 | if err != nil { 133 | re := regexp.MustCompile(`executable file not found`) 134 | if re.MatchString(err.Error()) { 135 | if ctx.Debug { 136 | log.Printf("executable file \"%s\" not found", cliName) 137 | } 138 | 139 | ctx.Success = false 140 | return "", nil 141 | } 142 | 143 | return "", err 144 | } 145 | if len(result) > 0 { 146 | result = strings.TrimRight(result, "\n") 147 | } 148 | return result, err 149 | } 150 | 151 | func tabular(headers []string, rows [][]string, asMarkdown bool) string { 152 | if asMarkdown { 153 | return markdown(headers, rows) 154 | } 155 | renderer := lipgloss.NewRenderer(os.Stdout) 156 | 157 | // Get terminal width, default to 80 if unavailable 158 | width, _, err := term.GetSize(os.Stdout.Fd()) 159 | if err != nil || width == 0 { 160 | width = 80 161 | } 162 | 163 | return table.New(). 164 | Headers(headers...). 165 | Rows(rows...). 166 | Border(lipgloss.ThickBorder()). 167 | BorderStyle(renderer.NewStyle().Foreground(lipgloss.Color("238"))). 168 | BorderRow(true). 169 | Width(width). 170 | Wrap(true). 171 | StyleFunc(func(_, _ int) lipgloss.Style { 172 | return renderer.NewStyle().Padding(0, 1) 173 | }).String() 174 | } 175 | 176 | func markdown(headers []string, rows [][]string) string { 177 | var buf strings.Builder 178 | 179 | // Header row. 180 | buf.WriteString("| " + strings.Join(headers, " | ") + " |\n") 181 | 182 | // Separator row. 183 | buf.WriteString("|") 184 | for range headers { 185 | buf.WriteString("---|") 186 | } 187 | buf.WriteString("\n") 188 | 189 | // Data rows. 190 | for _, row := range rows { 191 | row[1] = strings.ReplaceAll(row[1], "\n", "
") 192 | buf.WriteString("| " + strings.Join(row, " | ") + " |\n") 193 | } 194 | 195 | return buf.String() 196 | } 197 | 198 | func success(ctx *types.Context, msg string) { 199 | fmt.Println(msg) //nolint:forbidigo 200 | ctx.Success = true 201 | } 202 | 203 | func summary(ctx *types.Context, attr string, nth int, asJSON bool, asMarkdown bool) error { 204 | if attr == "os" { 205 | return osSummary(ctx, asJSON, asMarkdown) 206 | } 207 | if attr == "battery" { 208 | batt, err := batterySummary(ctx, nth, asJSON, asMarkdown) 209 | if err != nil { 210 | return err 211 | } 212 | success(ctx, batt) 213 | return nil 214 | } 215 | if attr == "var" { 216 | return envSummary(ctx, asJSON, asMarkdown) 217 | } 218 | return fmt.Errorf("unknown attribute: %s", attr) 219 | } 220 | 221 | func toJSON(record any) (string, error) { 222 | data, err := json.MarshalIndent(record, "", " ") 223 | if err != nil { 224 | return "", fmt.Errorf("could not marshal indented JSON (%+v): %w", record, err) 225 | } 226 | 227 | return string(data), nil 228 | } 229 | 230 | func osSummary(ctx *types.Context, asJSON bool, asMarkdown bool) error { 231 | summary, err := is_os.ReleaseSummary(ctx) 232 | if err != nil { 233 | return err 234 | } 235 | if asJSON { 236 | result, err := toJSON(summary) 237 | if err != nil { 238 | return err 239 | } 240 | success(ctx, result) 241 | return nil 242 | } 243 | headers := []string{ 244 | "Attribute", 245 | "Value", 246 | } 247 | 248 | rows := [][]string{ 249 | {"name", summary.Name}, 250 | {"version", summary.Version}, 251 | } 252 | 253 | if summary.VersionCodeName != "" { 254 | rows = append(rows, []string{"version-codename", summary.VersionCodeName}) 255 | } 256 | if summary.ID != "" { 257 | rows = append(rows, []string{"id", summary.ID}) 258 | } 259 | if summary.IDLike != "" { 260 | rows = append(rows, []string{"id-like", summary.IDLike}) 261 | } 262 | if summary.PrettyName != "" { 263 | rows = append(rows, []string{"pretty-name", summary.PrettyName}) 264 | } 265 | success(ctx, tabular(headers, rows, asMarkdown)) 266 | return nil 267 | } 268 | 269 | func batterySummary(ctx *types.Context, nth int, asJSON bool, asMarkdown bool) (string, error) { 270 | summary, err := battery.Get(ctx, nth) 271 | if err != nil { 272 | return "", err 273 | } 274 | if asJSON { 275 | if summary.Count == 0 { 276 | result, err := toJSON(map[string]int{"count": 0}) 277 | if err != nil { 278 | return "", err 279 | } 280 | return result, nil 281 | } 282 | result, err := toJSON(summary) 283 | if err != nil { 284 | return "", err 285 | } 286 | return result, nil 287 | } 288 | 289 | headers := []string{ 290 | "Attribute", 291 | "Value", 292 | } 293 | 294 | var rows [][]string 295 | 296 | if summary.Count > 0 { 297 | rows = [][]string{ 298 | {"battery-number", fmt.Sprintf("%d", summary.BatteryNumber)}, 299 | {"charge-rate", fmt.Sprintf("%v mW", summary.ChargeRate)}, 300 | {"count", fmt.Sprintf("%d", summary.Count)}, 301 | {"current-capacity", fmt.Sprintf("%v mWh", summary.CurrentCapacity)}, 302 | {"current-charge", fmt.Sprintf("%v %%", summary.CurrentCharge)}, 303 | {"design-capacity", fmt.Sprintf("%v mWh", summary.DesignCapacity)}, 304 | {"design-voltage", fmt.Sprintf("%v mWh", summary.DesignVoltage)}, 305 | {"last-full-capacity", fmt.Sprintf("%v mWh", summary.LastFullCapacity)}, 306 | {"state", fmt.Sprintf("%v", summary.State)}, 307 | {"voltage", fmt.Sprintf("%v V", summary.Voltage)}, 308 | } 309 | } else { 310 | rows = append(rows, []string{"count", "0"}) 311 | } 312 | return tabular(headers, rows, asMarkdown), nil 313 | } 314 | 315 | func shouldSplitEnvVar(name string) bool { 316 | return name == path || name == manpath || name == xdgConfigDirs || name == xdgDataDirs 317 | } 318 | 319 | func envSummary(ctx *types.Context, asJSON bool, asMarkdown bool) error { 320 | envMap := make(map[string]any) 321 | rows := make([][]string, 0, len(os.Environ())) 322 | 323 | envVars := os.Environ() 324 | sort.Strings(envVars) 325 | 326 | for _, entry := range envVars { 327 | parts := strings.SplitN(entry, "=", 2) 328 | if len(parts) != 2 { 329 | continue 330 | } 331 | 332 | name, value := parts[0], parts[1] 333 | 334 | if shouldSplitEnvVar(name) { 335 | pathParts := strings.Split(value, ":") 336 | if asJSON { 337 | envMap[name] = pathParts 338 | } else { 339 | rows = append(rows, []string{name, strings.Join(pathParts, "\n")}) 340 | } 341 | continue 342 | } 343 | 344 | // Handle regular environment variables 345 | if asJSON { 346 | envMap[name] = value 347 | } else { 348 | rows = append(rows, []string{name, value}) 349 | } 350 | } 351 | 352 | if asJSON { 353 | result, err := toJSON(envMap) 354 | if err != nil { 355 | return err 356 | } 357 | success(ctx, result) 358 | return nil 359 | } 360 | 361 | // Tabular output 362 | headers := []string{"Name", "Value"} 363 | success(ctx, tabular(headers, rows, asMarkdown)) 364 | return nil 365 | } 366 | 367 | func getEnv(ctx *types.Context, name string, asJSON bool) (string, error) { 368 | value, set := os.LookupEnv(name) 369 | ctx.Success = set 370 | 371 | if !asJSON { 372 | return value, nil 373 | } 374 | 375 | values := []string{} 376 | 377 | switch { 378 | case set && shouldSplitEnvVar(name): 379 | values = strings.Split(value, ":") 380 | case set: 381 | values = append(values, value) 382 | default: 383 | values = nil 384 | } 385 | 386 | result, err := toJSON(values) 387 | if err != nil { 388 | return "", err 389 | } 390 | 391 | return result, nil 392 | } 393 | -------------------------------------------------------------------------------- /LICENSE-APACHE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "[]" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright [yyyy] [name of copyright owner] 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. 202 | -------------------------------------------------------------------------------- /compare/compare_test.go: -------------------------------------------------------------------------------- 1 | package compare_test 2 | 3 | import ( 4 | "fmt" 5 | "strings" 6 | "testing" 7 | 8 | "github.com/oalders/is/compare" 9 | "github.com/oalders/is/ops" 10 | "github.com/oalders/is/types" 11 | "github.com/stretchr/testify/assert" 12 | "github.com/stretchr/testify/require" 13 | ) 14 | 15 | type compareTest struct { 16 | Op string 17 | Got string 18 | Want string 19 | Segment uint 20 | Error bool 21 | Success bool 22 | Debug bool 23 | } 24 | 25 | func TestVersions(t *testing.T) { 26 | t.Parallel() 27 | tests := []compareTest{ 28 | { 29 | Op: ops.Gt, 30 | Got: "3.3", 31 | Want: "3.3", 32 | Success: false, 33 | }, 34 | {Op: ops.Ne, Got: "3.3", Want: "3.3", Success: false}, 35 | {Op: ops.Eq, Got: "3.3", Want: "3.3", Success: true}, 36 | {Op: ops.Gte, Got: "3.3", Want: "3.3", Success: true}, 37 | {Op: ops.In, Got: "3.3", Want: "3.3,4.4", Success: true}, 38 | {Op: ops.In, Got: "3.3", Want: "4.4", Success: false}, 39 | {Op: ops.Lte, Got: "3.3", Want: "3.3", Success: true}, 40 | {Op: ops.Lt, Got: "3.3", Want: "3.3", Success: false}, 41 | {Op: ops.Like, Got: "3.3", Want: "3.3", Success: true}, 42 | {Op: ops.Unlike, Got: "3.3", Want: "4", Success: true}, 43 | 44 | {Op: ops.Gt, Got: "3.3a", Want: "3.3a", Success: false}, 45 | {Op: ops.Ne, Got: "3.3a", Want: "3.3a", Success: false}, 46 | {Op: ops.Eq, Got: "3.3a", Want: "3.3a", Success: true}, 47 | {Op: ops.Gte, Got: "3.3a", Want: "3.3a", Success: true}, 48 | {Op: ops.Lte, Got: "3.3a", Want: "3.3a", Success: true}, 49 | {Op: ops.Lt, Got: "3.3a", Want: "3.3a", Success: false}, 50 | {Op: ops.Like, Got: "3.3a", Want: "3.3a", Success: true}, 51 | {Op: ops.Unlike, Got: "3.3a", Want: "4", Success: true}, 52 | 53 | {Op: ops.Gt, Got: "2", Want: "1", Success: true}, 54 | {Op: ops.Ne, Got: "2", Want: "1", Success: true}, 55 | {Op: ops.Eq, Got: "2", Want: "1", Success: false}, 56 | {Op: ops.Gte, Got: "2", Want: "1", Success: true}, 57 | {Op: ops.Lte, Got: "2", Want: "1", Success: false}, 58 | {Op: ops.Lt, Got: "2", Want: "1", Success: false}, 59 | {Op: ops.Like, Got: "2", Want: "1", Success: false}, 60 | {Op: ops.Unlike, Got: "2", Want: "1", Success: true}, 61 | 62 | {Op: ops.Gt, Got: "1", Want: "2", Success: false}, 63 | {Op: ops.Ne, Got: "1", Want: "2", Success: true}, 64 | {Op: ops.Eq, Got: "1", Want: "2", Success: false}, 65 | {Op: ops.Gte, Got: "1", Want: "2", Success: false}, 66 | {Op: ops.Lte, Got: "1", Want: "2", Success: true}, 67 | {Op: ops.Lt, Got: "1", Want: "2", Success: true}, 68 | {Op: ops.Like, Got: "1", Want: "2", Success: false}, 69 | {Op: ops.Unlike, Got: "1", Want: "2", Success: true}, 70 | } 71 | 72 | testTable(t, tests, 73 | func(ctx *types.Context, this compareTest) (bool, error) { 74 | return compare.Versions(ctx, this.Op, this.Got, this.Want) 75 | }, 76 | ) 77 | { 78 | ctx := &types.Context{} 79 | ok, err := compare.Versions(ctx, ops.In, "3.3", strings.Repeat("3,", 100)) 80 | assert.Error(t, err) 81 | assert.False(t, ok) 82 | } 83 | { 84 | ctx := &types.Context{} 85 | ok, err := compare.Versions(ctx, ops.In, "3.3", "!!x") 86 | assert.Error(t, err) 87 | assert.False(t, ok) 88 | } 89 | } 90 | 91 | func TestCompareVersionSegments(t *testing.T) { 92 | t.Parallel() 93 | tests := []compareTest{ 94 | { 95 | Op: ops.Eq, 96 | Got: "3.3", 97 | Want: "3", 98 | Segment: 0, 99 | Error: false, 100 | Success: true, 101 | }, 102 | { 103 | Op: ops.Eq, 104 | Got: "3.3", 105 | Want: "3", 106 | Segment: 1, 107 | Error: false, 108 | Success: true, 109 | }, 110 | { 111 | Op: ops.Eq, 112 | Got: "3.3", 113 | Want: "0", 114 | Segment: 2, 115 | Error: false, 116 | Success: true, 117 | }, 118 | { 119 | Op: ops.In, 120 | Got: "3.3", 121 | Want: "1,2,3,4", 122 | Segment: 0, 123 | Error: false, 124 | Success: true, 125 | }, 126 | { 127 | Op: ops.In, 128 | Got: "3.3", 129 | Want: "4,5,6", 130 | Segment: 0, 131 | Error: false, 132 | Success: false, 133 | }, 134 | { 135 | Op: ops.In, 136 | Got: "3.3", 137 | Want: "4.0,5,6", 138 | Segment: 0, 139 | Error: true, 140 | Success: false, 141 | }, 142 | { 143 | Op: ops.In, 144 | Got: "3.3", 145 | Want: strings.Repeat("X,", 100), 146 | Segment: 0, 147 | Error: true, 148 | Success: false, 149 | }, 150 | { 151 | Op: ops.Like, 152 | Got: "3.3", 153 | Want: "3", 154 | Segment: 0, 155 | Error: false, 156 | Success: true, 157 | }, 158 | { 159 | Op: ops.Like, 160 | Got: "3.3", 161 | Want: "3", 162 | Segment: 1, 163 | Error: false, 164 | Success: true, 165 | }, 166 | { 167 | Op: ops.Like, 168 | Got: "3.3", 169 | Want: "0", 170 | Segment: 2, 171 | Error: false, 172 | Success: true, 173 | }, 174 | { 175 | Op: ops.Like, 176 | Got: "!!x]", 177 | Want: "0", 178 | Segment: 2, 179 | Error: true, 180 | Success: false, 181 | }, 182 | } 183 | 184 | for _, v := range tests { //nolint:varnamelen 185 | label := fmt.Sprintf("%s %s %s %d", v.Got, v.Op, v.Want, v.Segment) 186 | ctx := &types.Context{Debug: false} 187 | success, err := compare.VersionSegment(ctx, v.Op, v.Got, v.Want, v.Segment) 188 | if v.Error { 189 | assert.Error(t, err) 190 | } else { 191 | require.NoError(t, err) 192 | } 193 | if v.Success { 194 | assert.True(t, success, label+"success ") 195 | } else { 196 | assert.False(t, success, label+"failure") 197 | } 198 | } 199 | } 200 | 201 | func TestStrings(t *testing.T) { 202 | t.Parallel() 203 | 204 | tests := []compareTest{ 205 | { 206 | Op: ops.Like, 207 | Got: "delboy trotter", 208 | Want: "delboy", 209 | Error: false, 210 | Success: true, 211 | Debug: false, 212 | }, 213 | { 214 | Op: ops.Unlike, 215 | Got: "delboy trotter", 216 | Want: "delboy", 217 | Error: false, 218 | Success: false, 219 | Debug: false, 220 | }, 221 | { 222 | Op: ops.Like, 223 | Got: "delboy trotter", 224 | Want: "Zdelboy", 225 | Error: false, 226 | Success: false, 227 | Debug: false, 228 | }, 229 | { 230 | Op: ops.Unlike, 231 | Got: "delboy trotter", 232 | Want: "Zdelboy", 233 | Error: false, 234 | Success: true, 235 | Debug: false, 236 | }, 237 | { 238 | Op: ops.Like, 239 | Got: "delboy trotter", 240 | Want: "/[/", 241 | Error: true, 242 | Success: false, 243 | Debug: false, 244 | }, 245 | { 246 | Op: ops.Unlike, 247 | Got: "delboy trotter", 248 | Want: "/[/", 249 | Error: true, 250 | Success: false, 251 | Debug: false, 252 | }, 253 | { 254 | Op: ops.Like, 255 | Got: "delboy trotter", 256 | Want: "delboy", 257 | Error: false, 258 | Success: true, 259 | Debug: true, 260 | }, 261 | { 262 | Op: ops.Like, 263 | Got: "delboy trotter", 264 | Want: "delboyD", 265 | Error: false, 266 | Success: false, 267 | Debug: true, 268 | }, 269 | { 270 | Op: ops.In, 271 | Got: "delboy trotter", 272 | Want: "delboy trotter, rodney trotter", 273 | Error: false, 274 | Success: true, 275 | Debug: false, 276 | }, 277 | { 278 | Op: ops.In, 279 | Got: "X", 280 | Want: strings.Repeat("X,", 99), 281 | Error: false, 282 | Success: true, 283 | Debug: false, 284 | }, 285 | { 286 | Op: ops.In, 287 | Got: "X", 288 | Want: strings.Repeat("X,", 100), 289 | Error: true, 290 | Success: false, 291 | Debug: false, 292 | }, 293 | } 294 | 295 | testTable(t, tests, 296 | func(ctx *types.Context, this compareTest) (bool, error) { 297 | return compare.Strings(ctx, this.Op, this.Got, this.Want) 298 | }, 299 | ) 300 | } 301 | 302 | func TestOptimistic(t *testing.T) { 303 | t.Parallel() 304 | 305 | tests := []compareTest{ 306 | { 307 | Op: ops.Like, 308 | Got: "delboy trotter", 309 | Want: "delboy", 310 | Error: false, 311 | Success: true, 312 | Debug: false, 313 | }, 314 | { 315 | Op: ops.Unlike, 316 | Got: "delboy trotter", 317 | Want: "delboy", 318 | Error: false, 319 | Success: false, 320 | Debug: false, 321 | }, 322 | { 323 | Op: ops.Like, 324 | Got: "delboy trotter", 325 | Want: "Zdelboy", 326 | Error: false, 327 | Success: false, 328 | Debug: false, 329 | }, 330 | { 331 | Op: ops.Unlike, 332 | Got: "delboy trotter", 333 | Want: "Zdelboy", 334 | Error: false, 335 | Success: true, 336 | Debug: false, 337 | }, 338 | { 339 | Op: ops.Like, 340 | Got: "delboy trotter", 341 | Want: "/[/", 342 | Error: false, 343 | Success: false, 344 | Debug: false, 345 | }, 346 | { 347 | Op: ops.Unlike, 348 | Got: "delboy trotter", 349 | Want: "/[/", 350 | Error: false, 351 | Success: false, 352 | Debug: false, 353 | }, 354 | { 355 | Op: ops.Like, 356 | Got: "delboy trotter", 357 | Want: "delboy", 358 | Error: false, 359 | Success: true, 360 | Debug: true, 361 | }, 362 | { 363 | Op: ops.Like, 364 | Got: "delboy trotter", 365 | Want: "delboyD", 366 | Error: false, 367 | Success: false, 368 | Debug: true, 369 | }, 370 | { 371 | Op: ops.Gte, 372 | Got: "1", 373 | Want: "1", 374 | Error: false, 375 | Success: true, 376 | Debug: true, 377 | }, 378 | { 379 | Op: ops.Eq, 380 | Got: "1.0", 381 | Want: "1", 382 | Error: false, 383 | Success: true, 384 | Debug: true, 385 | }, 386 | { 387 | Op: ops.Ne, 388 | Got: "1", 389 | Want: "2", 390 | Error: false, 391 | Success: true, 392 | Debug: true, 393 | }, 394 | { 395 | Op: ops.Ne, 396 | Got: "a", 397 | Want: "2", 398 | Error: false, 399 | Success: true, 400 | Debug: true, 401 | }, 402 | { 403 | Op: ops.Gte, 404 | Got: "/[/", 405 | Want: "1", 406 | Error: false, 407 | Success: false, 408 | Debug: true, 409 | }, 410 | { 411 | Op: ops.Gte, 412 | Got: "1", 413 | Want: "/[/", 414 | Error: false, 415 | Success: false, 416 | Debug: true, 417 | }, 418 | { 419 | Op: ops.In, 420 | Got: "X", 421 | Want: strings.Repeat("X,", 100), 422 | Error: false, 423 | Success: false, 424 | Debug: false, 425 | }, 426 | } 427 | 428 | testTable(t, tests, 429 | func(ctx *types.Context, this compareTest) (bool, error) { 430 | return compare.Optimistic(ctx, this.Op, this.Got, this.Want), nil 431 | }, 432 | ) 433 | } 434 | 435 | func TestIntegers(t *testing.T) { 436 | t.Parallel() 437 | 438 | tests := []compareTest{ 439 | { 440 | Op: ops.Eq, 441 | Got: "1", 442 | Want: "1", 443 | Error: false, 444 | Success: true, 445 | Debug: true, 446 | }, 447 | { 448 | Op: ops.Gte, 449 | Got: "1", 450 | Want: "1", 451 | Error: false, 452 | Success: true, 453 | Debug: true, 454 | }, 455 | { 456 | Op: ops.Gt, 457 | Got: "1", 458 | Want: "1", 459 | Error: false, 460 | Success: false, 461 | Debug: true, 462 | }, 463 | { 464 | Op: ops.Gte, 465 | Got: "2", 466 | Want: "1", 467 | Error: false, 468 | Success: true, 469 | Debug: true, 470 | }, 471 | { 472 | Op: ops.In, 473 | Got: "1", 474 | Want: "0,1", 475 | Error: false, 476 | Success: true, 477 | Debug: true, 478 | }, 479 | { 480 | Op: ops.In, 481 | Got: "1", 482 | Want: "2,3", 483 | Error: false, 484 | Success: false, 485 | Debug: true, 486 | }, 487 | { 488 | Op: ops.In, 489 | Got: "1", 490 | Want: "2.0,3.0", 491 | Error: true, 492 | Success: false, 493 | Debug: true, 494 | }, 495 | { 496 | Op: ops.Lt, 497 | Got: "1", 498 | Want: "1", 499 | Error: false, 500 | Success: false, 501 | Debug: true, 502 | }, 503 | { 504 | Op: ops.Lte, 505 | Got: "1", 506 | Want: "1", 507 | Error: false, 508 | Success: true, 509 | Debug: true, 510 | }, 511 | { 512 | Op: ops.Ne, 513 | Got: "1", 514 | Want: "2", 515 | Error: false, 516 | Success: true, 517 | Debug: true, 518 | }, 519 | { 520 | Op: ops.Ne, 521 | Got: "a", 522 | Want: "2", 523 | Error: true, 524 | Success: false, 525 | Debug: true, 526 | }, 527 | { 528 | Op: ops.Gte, 529 | Got: "/[/", 530 | Want: "1", 531 | Error: true, 532 | Success: false, 533 | Debug: true, 534 | }, 535 | { 536 | Op: ops.Gte, 537 | Got: "1", 538 | Want: "/[/", 539 | Error: true, 540 | Success: false, 541 | Debug: true, 542 | }, 543 | { 544 | Op: ops.In, 545 | Got: "X", 546 | Want: strings.Repeat("X,", 100), 547 | Error: true, 548 | Success: false, 549 | Debug: false, 550 | }, 551 | } 552 | 553 | testTable(t, tests, 554 | func(ctx *types.Context, this compareTest) (bool, error) { 555 | return compare.Integers(ctx, this.Op, this.Got, this.Want) 556 | }, 557 | ) 558 | } 559 | 560 | func TestFloats(t *testing.T) { 561 | t.Parallel() 562 | 563 | tests := []compareTest{ 564 | { 565 | Op: ops.Eq, 566 | Got: "1", 567 | Want: "1", 568 | Error: false, 569 | Success: true, 570 | Debug: true, 571 | }, 572 | { 573 | Op: ops.Eq, 574 | Got: "1.0", 575 | Want: "1", 576 | Error: false, 577 | Success: true, 578 | Debug: true, 579 | }, 580 | { 581 | Op: ops.Eq, 582 | Got: "1", 583 | Want: "1.0", 584 | Error: false, 585 | Success: true, 586 | Debug: true, 587 | }, 588 | { 589 | Op: ops.Gte, 590 | Got: "1", 591 | Want: "1", 592 | Error: false, 593 | Success: true, 594 | Debug: true, 595 | }, 596 | { 597 | Op: ops.Gte, 598 | Got: "2", 599 | Want: "1", 600 | Error: false, 601 | Success: true, 602 | Debug: true, 603 | }, 604 | { 605 | Op: ops.In, 606 | Got: "1.0", 607 | Want: "1.0,2.0", 608 | Error: false, 609 | Success: true, 610 | Debug: true, 611 | }, 612 | { 613 | Op: ops.In, 614 | Got: "1.0", 615 | Want: "2.0,3.0", 616 | Error: false, 617 | Success: false, 618 | Debug: false, 619 | }, 620 | { 621 | Op: ops.In, 622 | Got: "1.0", 623 | Want: "2.0,3.0,X", 624 | Error: true, 625 | Success: false, 626 | Debug: false, 627 | }, 628 | { 629 | Op: ops.Ne, 630 | Got: "1", 631 | Want: "2", 632 | Error: false, 633 | Success: true, 634 | Debug: true, 635 | }, 636 | { 637 | Op: ops.Ne, 638 | Got: "a", 639 | Want: "2", 640 | Error: true, 641 | Success: false, 642 | Debug: true, 643 | }, 644 | { 645 | Op: ops.Gte, 646 | Got: "/[/", 647 | Want: "1", 648 | Error: true, 649 | Success: false, 650 | Debug: true, 651 | }, 652 | { 653 | Op: ops.Gte, 654 | Got: "1", 655 | Want: "/[/", 656 | Error: true, 657 | Success: false, 658 | Debug: true, 659 | }, 660 | { 661 | Op: ops.In, 662 | Got: "1.0", 663 | Want: strings.Repeat("1.0,", 100), 664 | Error: true, 665 | Success: false, 666 | Debug: false, 667 | }, 668 | } 669 | 670 | testTable(t, tests, 671 | func(ctx *types.Context, this compareTest) (bool, error) { 672 | return compare.Floats(ctx, this.Op, this.Got, this.Want) 673 | }, 674 | ) 675 | } 676 | 677 | func testTable( //nolint:thelper 678 | t *testing.T, 679 | tests []compareTest, 680 | comparison func(ctx *types.Context, this compareTest) (bool, error), 681 | ) { 682 | for _, this := range tests { 683 | ctx := &types.Context{Debug: this.Debug} 684 | ok, err := comparison(ctx, this) 685 | label := fmt.Sprintf("[%s %s %s]", this.Got, this.Op, this.Want) 686 | assert.Equal(t, this.Success, ok, label) 687 | if this.Error { 688 | require.Error(t, err, label) 689 | } else { 690 | require.NoError(t, err, label) 691 | } 692 | } 693 | } 694 | --------------------------------------------------------------------------------