chore(repo): bootstrap nefertty skeleton, tooling and logging
- commit
- 4308b69dd41bd5779facd395059e8126fc5374a9
- author
- brice · · 2026-09-23 10:36 UTC
- parent
- none (root commit)
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
16 files · +412 -0
A .github/workflows/ci.yml +15 -0
| @@ -0,0 +1,15 @@ | |||
| 1 | + | name: CI | |
| 2 | + | on: [push, pull_request] | |
| 3 | + | jobs: | |
| 4 | + | check: | |
| 5 | + | runs-on: ubuntu-latest | |
| 6 | + | steps: | |
| 7 | + | - uses: actions/checkout@v4 | |
| 8 | + | - uses: actions/setup-go@v5 | |
| 9 | + | with: | |
| 10 | + | go-version: '1.27' | |
| 11 | + | - run: go install github.com/bnema/hexcheck@latest | |
| 12 | + | - run: go install github.com/vektra/mockery/v3@v3.7.4 | |
| 13 | + | - run: make check | |
| 14 | + | - run: make race | |
| 15 | + | - run: make mocks-check | |
| @@ -0,0 +1,15 @@ | |||||
| 1 | + | name: CI | |||
| 2 | + | on: [push, pull_request] | |||
| 3 | + | jobs: | |||
| 4 | + | check: | |||
| 5 | + | runs-on: ubuntu-latest | |||
| 6 | + | steps: | |||
| 7 | + | - uses: actions/checkout@v4 | |||
| 8 | + | - uses: actions/setup-go@v5 | |||
| 9 | + | with: | |||
| 10 | + | go-version: '1.27' | |||
| 11 | + | - run: go install github.com/bnema/hexcheck@latest | |||
| 12 | + | - run: go install github.com/vektra/mockery/v3@v3.7.4 | |||
| 13 | + | - run: make check | |||
| 14 | + | - run: make race | |||
| 15 | + | - run: make mocks-check | |||
A .gitignore +5 -0
| @@ -0,0 +1,5 @@ | |||
| 1 | + | /nefertty | |
| 2 | + | *.test | |
| 3 | + | *.out | |
| 4 | + | coverage/ | |
| 5 | + | .worktrees/ | |
| @@ -0,0 +1,5 @@ | |||||
| 1 | + | /nefertty | |||
| 2 | + | *.test | |||
| 3 | + | *.out | |||
| 4 | + | coverage/ | |||
| 5 | + | .worktrees/ | |||
A .hexcheck.yaml +13 -0
| @@ -0,0 +1,13 @@ | |||
| 1 | + | version: 1 | |
| 2 | + | components: | |
| 3 | + | core: { role: core, paths: [internal/core/**] } | |
| 4 | + | ports: { role: ports, paths: [internal/ports/**] } | |
| 5 | + | adapters: { role: adapter, paths: [internal/adapters/**, internal/logging/**] } | |
| 6 | + | # app is the composition root: it wires adapters to core, like cmd. | |
| 7 | + | entrypoints: { role: entrypoint, paths: [cmd/**, internal/app/**] } | |
| 8 | + | generated: { role: ignore, paths: [internal/mocks/**] } | |
| 9 | + | rules: | |
| 10 | + | no-adapter-imports-in-core: error | |
| 11 | + | no-infra-imports-in-usecase: error | |
| 12 | + | no-framework-types-in-core: error | |
| 13 | + | no-infra-types-in-ports: error | |
| @@ -0,0 +1,13 @@ | |||||
| 1 | + | version: 1 | |||
| 2 | + | components: | |||
| 3 | + | core: { role: core, paths: [internal/core/**] } | |||
| 4 | + | ports: { role: ports, paths: [internal/ports/**] } | |||
| 5 | + | adapters: { role: adapter, paths: [internal/adapters/**, internal/logging/**] } | |||
| 6 | + | # app is the composition root: it wires adapters to core, like cmd. | |||
| 7 | + | entrypoints: { role: entrypoint, paths: [cmd/**, internal/app/**] } | |||
| 8 | + | generated: { role: ignore, paths: [internal/mocks/**] } | |||
| 9 | + | rules: | |||
| 10 | + | no-adapter-imports-in-core: error | |||
| 11 | + | no-infra-imports-in-usecase: error | |||
| 12 | + | no-framework-types-in-core: error | |||
| 13 | + | no-infra-types-in-ports: error | |||
A .mockery.yml +9 -0
| @@ -0,0 +1,9 @@ | |||
| 1 | + | # No interfaces exist yet; Mockery v3 treats this package as a clean no-op. | |
| 2 | + | all: false | |
| 3 | + | packages: | |
| 4 | + | github.com/bnema/nefertty/internal/ports: | |
| 5 | + | config: | |
| 6 | + | all: true | |
| 7 | + | dir: internal/mocks/{{.SrcPackageName}} | |
| 8 | + | pkgname: '{{.SrcPackageName}}' | |
| 9 | + | filename: '{{.InterfaceName}}.go' | |
| @@ -0,0 +1,9 @@ | |||||
| 1 | + | # No interfaces exist yet; Mockery v3 treats this package as a clean no-op. | |||
| 2 | + | all: false | |||
| 3 | + | packages: | |||
| 4 | + | github.com/bnema/nefertty/internal/ports: | |||
| 5 | + | config: | |||
| 6 | + | all: true | |||
| 7 | + | dir: internal/mocks/{{.SrcPackageName}} | |||
| 8 | + | pkgname: '{{.SrcPackageName}}' | |||
| 9 | + | filename: '{{.InterfaceName}}.go' | |||
A AGENTS.md +9 -0
| @@ -0,0 +1,9 @@ | |||
| 1 | + | # Repository rules | |
| 2 | + | ||
| 3 | + | - Use Go 1.27 only; normal builds and tests use CGO_ENABLED=0. | |
| 4 | + | - Keep a lightweight hexagonal architecture: core imports only stdlib and ports. | |
| 5 | + | - Owner goroutines own window state; no mutex on window state. | |
| 6 | + | - Generate mocks only via Mockery v3 from ports; never handwrite mocks. | |
| 7 | + | - Use zerowrap logging with a `component` field on every entry. | |
| 8 | + | - Run tests with `-race`. | |
| 9 | + | - Use signed conventional commits: `type(scope): description`. | |
| @@ -0,0 +1,9 @@ | |||||
| 1 | + | # Repository rules | |||
| 2 | + | ||||
| 3 | + | - Use Go 1.27 only; normal builds and tests use CGO_ENABLED=0. | |||
| 4 | + | - Keep a lightweight hexagonal architecture: core imports only stdlib and ports. | |||
| 5 | + | - Owner goroutines own window state; no mutex on window state. | |||
| 6 | + | - Generate mocks only via Mockery v3 from ports; never handwrite mocks. | |||
| 7 | + | - Use zerowrap logging with a `component` field on every entry. | |||
| 8 | + | - Run tests with `-race`. | |||
| 9 | + | - Use signed conventional commits: `type(scope): description`. | |||
A LICENSE +21 -0
| @@ -0,0 +1,21 @@ | |||
| 1 | + | MIT License | |
| 2 | + | ||
| 3 | + | Copyright (c) 2026 bnema | |
| 4 | + | ||
| 5 | + | Permission is hereby granted, free of charge, to any person obtaining a copy | |
| 6 | + | of this software and associated documentation files (the "Software"), to deal | |
| 7 | + | in the Software without restriction, including without limitation the rights | |
| 8 | + | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell | |
| 9 | + | copies of the Software, and to permit persons to whom the Software is | |
| 10 | + | furnished to do so, subject to the following conditions: | |
| 11 | + | ||
| 12 | + | The above copyright notice and this permission notice shall be included in all | |
| 13 | + | copies or substantial portions of the Software. | |
| 14 | + | ||
| 15 | + | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR | |
| 16 | + | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, | |
| 17 | + | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE | |
| 18 | + | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER | |
| 19 | + | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, | |
| 20 | + | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE | |
| 21 | + | SOFTWARE. | |
| @@ -0,0 +1,21 @@ | |||||
| 1 | + | MIT License | |||
| 2 | + | ||||
| 3 | + | Copyright (c) 2026 bnema | |||
| 4 | + | ||||
| 5 | + | Permission is hereby granted, free of charge, to any person obtaining a copy | |||
| 6 | + | of this software and associated documentation files (the "Software"), to deal | |||
| 7 | + | in the Software without restriction, including without limitation the rights | |||
| 8 | + | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell | |||
| 9 | + | copies of the Software, and to permit persons to whom the Software is | |||
| 10 | + | furnished to do so, subject to the following conditions: | |||
| 11 | + | ||||
| 12 | + | The above copyright notice and this permission notice shall be included in all | |||
| 13 | + | copies or substantial portions of the Software. | |||
| 14 | + | ||||
| 15 | + | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR | |||
| 16 | + | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, | |||
| 17 | + | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE | |||
| 18 | + | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER | |||
| 19 | + | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, | |||
| 20 | + | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE | |||
| 21 | + | SOFTWARE. | |||
A Makefile +18 -0
| @@ -0,0 +1,18 @@ | |||
| 1 | + | .PHONY: build test vet race mocks mocks-check arch check | |
| 2 | + | build: | |
| 3 | + | CGO_ENABLED=0 go build ./... | |
| 4 | + | test: | |
| 5 | + | CGO_ENABLED=0 go test ./... | |
| 6 | + | vet: | |
| 7 | + | CGO_ENABLED=0 go vet ./... | |
| 8 | + | race: | |
| 9 | + | # cgo is enabled only for the race test binary. | |
| 10 | + | CGO_ENABLED=1 go test -race ./... | |
| 11 | + | mocks: | |
| 12 | + | $(HOME)/go/bin/mockery | |
| 13 | + | mocks-check: mocks | |
| 14 | + | git diff --exit-code -- internal/mocks | |
| 15 | + | @test -z "$$(git ls-files --others --exclude-standard internal/mocks)" | |
| 16 | + | arch: | |
| 17 | + | $(HOME)/go/bin/hexcheck -hexcheck.config .hexcheck.yaml -hexcheck.root . ./... | |
| 18 | + | check: vet test arch | |
| @@ -0,0 +1,18 @@ | |||||
| 1 | + | .PHONY: build test vet race mocks mocks-check arch check | |||
| 2 | + | build: | |||
| 3 | + | CGO_ENABLED=0 go build ./... | |||
| 4 | + | test: | |||
| 5 | + | CGO_ENABLED=0 go test ./... | |||
| 6 | + | vet: | |||
| 7 | + | CGO_ENABLED=0 go vet ./... | |||
| 8 | + | race: | |||
| 9 | + | # cgo is enabled only for the race test binary. | |||
| 10 | + | CGO_ENABLED=1 go test -race ./... | |||
| 11 | + | mocks: | |||
| 12 | + | $(HOME)/go/bin/mockery | |||
| 13 | + | mocks-check: mocks | |||
| 14 | + | git diff --exit-code -- internal/mocks | |||
| 15 | + | @test -z "$$(git ls-files --others --exclude-standard internal/mocks)" | |||
| 16 | + | arch: | |||
| 17 | + | $(HOME)/go/bin/hexcheck -hexcheck.config .hexcheck.yaml -hexcheck.root . ./... | |||
| 18 | + | check: vet test arch | |||
A README.md +9 -0
| @@ -0,0 +1,9 @@ | |||
| 1 | + | # NeferTTY | |
| 2 | + | ||
| 3 | + | NeferTTY is a minimal Wayland compositor in Go with Niri-style scrollable columns. This repository currently contains only the skeleton and logging. | |
| 4 | + | ||
| 5 | + | Requires Linux and Go 1.27. | |
| 6 | + | ||
| 7 | + | Build: `make build` | |
| 8 | + | ||
| 9 | + | Run: `nefertty --backend=headless --timeout 5s` | |
| @@ -0,0 +1,9 @@ | |||||
| 1 | + | # NeferTTY | |||
| 2 | + | ||||
| 3 | + | NeferTTY is a minimal Wayland compositor in Go with Niri-style scrollable columns. This repository currently contains only the skeleton and logging. | |||
| 4 | + | ||||
| 5 | + | Requires Linux and Go 1.27. | |||
| 6 | + | ||||
| 7 | + | Build: `make build` | |||
| 8 | + | ||||
| 9 | + | Run: `nefertty --backend=headless --timeout 5s` | |||
A cmd/nefertty/main.go +73 -0
| @@ -0,0 +1,73 @@ | |||
| 1 | + | package main | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | "flag" | |
| 6 | + | "fmt" | |
| 7 | + | "os" | |
| 8 | + | "os/signal" | |
| 9 | + | "runtime/debug" | |
| 10 | + | "syscall" | |
| 11 | + | ||
| 12 | + | "github.com/bnema/nefertty/internal/app" | |
| 13 | + | "github.com/bnema/nefertty/internal/logging" | |
| 14 | + | ) | |
| 15 | + | ||
| 16 | + | func main() { | |
| 17 | + | if err := run(); err != nil { | |
| 18 | + | os.Exit(1) | |
| 19 | + | } | |
| 20 | + | } | |
| 21 | + | ||
| 22 | + | func run() error { | |
| 23 | + | if len(os.Args) == 2 && os.Args[1] == "version" { | |
| 24 | + | info, ok := debug.ReadBuildInfo() | |
| 25 | + | if !ok { | |
| 26 | + | fmt.Println("unknown") | |
| 27 | + | return nil | |
| 28 | + | } | |
| 29 | + | version := info.Main.Version | |
| 30 | + | if version == "" { | |
| 31 | + | version = "(devel)" | |
| 32 | + | } | |
| 33 | + | fmt.Println(version) | |
| 34 | + | return nil | |
| 35 | + | } | |
| 36 | + | flags := flag.NewFlagSet("nefertty", flag.ContinueOnError) | |
| 37 | + | backend := flags.String("backend", "drm", "drm or headless") | |
| 38 | + | timeout := flags.Duration("timeout", 0, "duration before exit (0 disables timeout)") | |
| 39 | + | debugFlag := flags.String("debug", "", "debug components (comma-separated or all)") | |
| 40 | + | config := flags.String("config", "", "config path (empty uses XDG default later)") | |
| 41 | + | if err := flags.Parse(os.Args[1:]); err != nil { | |
| 42 | + | return err | |
| 43 | + | } | |
| 44 | + | ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) | |
| 45 | + | defer stop() | |
| 46 | + | ctx, closeLog, err := logging.Open(ctx, *debugFlag) | |
| 47 | + | if err != nil { | |
| 48 | + | fmt.Fprintln(os.Stderr, err) | |
| 49 | + | return err | |
| 50 | + | } | |
| 51 | + | defer closeLog() | |
| 52 | + | log := logging.For(ctx, "app") | |
| 53 | + | if *backend != "drm" && *backend != "headless" { | |
| 54 | + | err := fmt.Errorf("invalid backend %q", *backend) | |
| 55 | + | log.Error().Err(err).Msg("invalid options") | |
| 56 | + | return err | |
| 57 | + | } | |
| 58 | + | if *timeout < 0 { | |
| 59 | + | err := fmt.Errorf("negative timeout") | |
| 60 | + | log.Error().Err(err).Msg("invalid options") | |
| 61 | + | return err | |
| 62 | + | } | |
| 63 | + | if flags.NArg() != 0 { | |
| 64 | + | err := fmt.Errorf("unexpected arguments: %v", flags.Args()) | |
| 65 | + | log.Error().Err(err).Msg("invalid options") | |
| 66 | + | return err | |
| 67 | + | } | |
| 68 | + | if err := app.Run(ctx, app.Options{Backend: *backend, Config: *config, Timeout: *timeout}); err != nil { | |
| 69 | + | log.Error().Err(err).Msg("application failed") | |
| 70 | + | return err | |
| 71 | + | } | |
| 72 | + | return nil | |
| 73 | + | } | |
| @@ -0,0 +1,73 @@ | |||||
| 1 | + | package main | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "context" | |||
| 5 | + | "flag" | |||
| 6 | + | "fmt" | |||
| 7 | + | "os" | |||
| 8 | + | "os/signal" | |||
| 9 | + | "runtime/debug" | |||
| 10 | + | "syscall" | |||
| 11 | + | ||||
| 12 | + | "github.com/bnema/nefertty/internal/app" | |||
| 13 | + | "github.com/bnema/nefertty/internal/logging" | |||
| 14 | + | ) | |||
| 15 | + | ||||
| 16 | + | func main() { | |||
| 17 | + | if err := run(); err != nil { | |||
| 18 | + | os.Exit(1) | |||
| 19 | + | } | |||
| 20 | + | } | |||
| 21 | + | ||||
| 22 | + | func run() error { | |||
| 23 | + | if len(os.Args) == 2 && os.Args[1] == "version" { | |||
| 24 | + | info, ok := debug.ReadBuildInfo() | |||
| 25 | + | if !ok { | |||
| 26 | + | fmt.Println("unknown") | |||
| 27 | + | return nil | |||
| 28 | + | } | |||
| 29 | + | version := info.Main.Version | |||
| 30 | + | if version == "" { | |||
| 31 | + | version = "(devel)" | |||
| 32 | + | } | |||
| 33 | + | fmt.Println(version) | |||
| 34 | + | return nil | |||
| 35 | + | } | |||
| 36 | + | flags := flag.NewFlagSet("nefertty", flag.ContinueOnError) | |||
| 37 | + | backend := flags.String("backend", "drm", "drm or headless") | |||
| 38 | + | timeout := flags.Duration("timeout", 0, "duration before exit (0 disables timeout)") | |||
| 39 | + | debugFlag := flags.String("debug", "", "debug components (comma-separated or all)") | |||
| 40 | + | config := flags.String("config", "", "config path (empty uses XDG default later)") | |||
| 41 | + | if err := flags.Parse(os.Args[1:]); err != nil { | |||
| 42 | + | return err | |||
| 43 | + | } | |||
| 44 | + | ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) | |||
| 45 | + | defer stop() | |||
| 46 | + | ctx, closeLog, err := logging.Open(ctx, *debugFlag) | |||
| 47 | + | if err != nil { | |||
| 48 | + | fmt.Fprintln(os.Stderr, err) | |||
| 49 | + | return err | |||
| 50 | + | } | |||
| 51 | + | defer closeLog() | |||
| 52 | + | log := logging.For(ctx, "app") | |||
| 53 | + | if *backend != "drm" && *backend != "headless" { | |||
| 54 | + | err := fmt.Errorf("invalid backend %q", *backend) | |||
| 55 | + | log.Error().Err(err).Msg("invalid options") | |||
| 56 | + | return err | |||
| 57 | + | } | |||
| 58 | + | if *timeout < 0 { | |||
| 59 | + | err := fmt.Errorf("negative timeout") | |||
| 60 | + | log.Error().Err(err).Msg("invalid options") | |||
| 61 | + | return err | |||
| 62 | + | } | |||
| 63 | + | if flags.NArg() != 0 { | |||
| 64 | + | err := fmt.Errorf("unexpected arguments: %v", flags.Args()) | |||
| 65 | + | log.Error().Err(err).Msg("invalid options") | |||
| 66 | + | return err | |||
| 67 | + | } | |||
| 68 | + | if err := app.Run(ctx, app.Options{Backend: *backend, Config: *config, Timeout: *timeout}); err != nil { | |||
| 69 | + | log.Error().Err(err).Msg("application failed") | |||
| 70 | + | return err | |||
| 71 | + | } | |||
| 72 | + | return nil | |||
| 73 | + | } | |||
A go.mod +16 -0
| @@ -0,0 +1,16 @@ | |||
| 1 | + | module github.com/bnema/nefertty | |
| 2 | + | ||
| 3 | + | go 1.27 | |
| 4 | + | ||
| 5 | + | require ( | |
| 6 | + | github.com/bnema/zerowrap v1.4.1 | |
| 7 | + | github.com/rs/zerolog v1.35.1 | |
| 8 | + | golang.org/x/term v0.46.0 | |
| 9 | + | ) | |
| 10 | + | ||
| 11 | + | require ( | |
| 12 | + | github.com/mattn/go-colorable v0.1.15 // indirect | |
| 13 | + | github.com/mattn/go-isatty v0.0.22 // indirect | |
| 14 | + | golang.org/x/sys v0.48.0 // indirect | |
| 15 | + | gopkg.in/natefinch/lumberjack.v2 v2.2.1 // indirect | |
| 16 | + | ) | |
| @@ -0,0 +1,16 @@ | |||||
| 1 | + | module github.com/bnema/nefertty | |||
| 2 | + | ||||
| 3 | + | go 1.27 | |||
| 4 | + | ||||
| 5 | + | require ( | |||
| 6 | + | github.com/bnema/zerowrap v1.4.1 | |||
| 7 | + | github.com/rs/zerolog v1.35.1 | |||
| 8 | + | golang.org/x/term v0.46.0 | |||
| 9 | + | ) | |||
| 10 | + | ||||
| 11 | + | require ( | |||
| 12 | + | github.com/mattn/go-colorable v0.1.15 // indirect | |||
| 13 | + | github.com/mattn/go-isatty v0.0.22 // indirect | |||
| 14 | + | golang.org/x/sys v0.48.0 // indirect | |||
| 15 | + | gopkg.in/natefinch/lumberjack.v2 v2.2.1 // indirect | |||
| 16 | + | ) | |||
A go.sum +14 -0
| @@ -0,0 +1,14 @@ | |||
| 1 | + | github.com/bnema/zerowrap v1.4.1 h1:v70w8LDU2+FU+EX/etY9DjVwOAWN0fUKShwZyZHnyLU= | |
| 2 | + | github.com/bnema/zerowrap v1.4.1/go.mod h1:8M8Qz3uRpbszYkXqi0Dnr1TZ4lDrQY8/orGm30zwKKg= | |
| 3 | + | github.com/mattn/go-colorable v0.1.15 h1:+u9SLTRGnXv73cEsnsmoZBom+dMU88B2M0aDcWy0/jY= | |
| 4 | + | github.com/mattn/go-colorable v0.1.15/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8= | |
| 5 | + | github.com/mattn/go-isatty v0.0.22 h1:j8l17JJ9i6VGPUFUYoTUKPSgKe/83EYU2zBC7YNKMw4= | |
| 6 | + | github.com/mattn/go-isatty v0.0.22/go.mod h1:ZXfXG4SQHsB/w3ZeOYbR0PrPwLy+n6xiMrJlRFqopa4= | |
| 7 | + | github.com/rs/zerolog v1.35.1 h1:m7xQeoiLIiV0BCEY4Hs+j2NG4Gp2o2KPKmhnnLiazKI= | |
| 8 | + | github.com/rs/zerolog v1.35.1/go.mod h1:EjML9kdfa/RMA7h/6z6pYmq1ykOuA8/mjWaEvGI+jcw= | |
| 9 | + | golang.org/x/sys v0.48.0 h1:bbX/i/6MgT9BVLM9RT1thmxL04yeTAhbEz4SyadbXoo= | |
| 10 | + | golang.org/x/sys v0.48.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og= | |
| 11 | + | golang.org/x/term v0.46.0 h1:3+OXuTbaKDgwk8jTi3aSLHRlmWqHEUDUtxnbFigO4YE= | |
| 12 | + | golang.org/x/term v0.46.0/go.mod h1:+K02xbkittuwc0Am4abfA3Fc+XRGXkvBXNO88NCXPoc= | |
| 13 | + | gopkg.in/natefinch/lumberjack.v2 v2.2.1 h1:bBRl1b0OH9s/DuPhuXpNl+VtCaJXFZ5/uEFST95x9zc= | |
| 14 | + | gopkg.in/natefinch/lumberjack.v2 v2.2.1/go.mod h1:YD8tP3GAjkrDg1eZH7EGmyESg/lsYskCTPBJVb9jqSc= | |
| @@ -0,0 +1,14 @@ | |||||
| 1 | + | github.com/bnema/zerowrap v1.4.1 h1:v70w8LDU2+FU+EX/etY9DjVwOAWN0fUKShwZyZHnyLU= | |||
| 2 | + | github.com/bnema/zerowrap v1.4.1/go.mod h1:8M8Qz3uRpbszYkXqi0Dnr1TZ4lDrQY8/orGm30zwKKg= | |||
| 3 | + | github.com/mattn/go-colorable v0.1.15 h1:+u9SLTRGnXv73cEsnsmoZBom+dMU88B2M0aDcWy0/jY= | |||
| 4 | + | github.com/mattn/go-colorable v0.1.15/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8= | |||
| 5 | + | github.com/mattn/go-isatty v0.0.22 h1:j8l17JJ9i6VGPUFUYoTUKPSgKe/83EYU2zBC7YNKMw4= | |||
| 6 | + | github.com/mattn/go-isatty v0.0.22/go.mod h1:ZXfXG4SQHsB/w3ZeOYbR0PrPwLy+n6xiMrJlRFqopa4= | |||
| 7 | + | github.com/rs/zerolog v1.35.1 h1:m7xQeoiLIiV0BCEY4Hs+j2NG4Gp2o2KPKmhnnLiazKI= | |||
| 8 | + | github.com/rs/zerolog v1.35.1/go.mod h1:EjML9kdfa/RMA7h/6z6pYmq1ykOuA8/mjWaEvGI+jcw= | |||
| 9 | + | golang.org/x/sys v0.48.0 h1:bbX/i/6MgT9BVLM9RT1thmxL04yeTAhbEz4SyadbXoo= | |||
| 10 | + | golang.org/x/sys v0.48.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og= | |||
| 11 | + | golang.org/x/term v0.46.0 h1:3+OXuTbaKDgwk8jTi3aSLHRlmWqHEUDUtxnbFigO4YE= | |||
| 12 | + | golang.org/x/term v0.46.0/go.mod h1:+K02xbkittuwc0Am4abfA3Fc+XRGXkvBXNO88NCXPoc= | |||
| 13 | + | gopkg.in/natefinch/lumberjack.v2 v2.2.1 h1:bBRl1b0OH9s/DuPhuXpNl+VtCaJXFZ5/uEFST95x9zc= | |||
| 14 | + | gopkg.in/natefinch/lumberjack.v2 v2.2.1/go.mod h1:YD8tP3GAjkrDg1eZH7EGmyESg/lsYskCTPBJVb9jqSc= | |||
A internal/app/app.go +27 -0
| @@ -0,0 +1,27 @@ | |||
| 1 | + | // Package app coordinates the application lifecycle. | |
| 2 | + | package app | |
| 3 | + | ||
| 4 | + | import ( | |
| 5 | + | "context" | |
| 6 | + | "time" | |
| 7 | + | ||
| 8 | + | "github.com/bnema/nefertty/internal/logging" | |
| 9 | + | ) | |
| 10 | + | ||
| 11 | + | type Options struct { | |
| 12 | + | Backend string | |
| 13 | + | Config string | |
| 14 | + | Timeout time.Duration | |
| 15 | + | } | |
| 16 | + | ||
| 17 | + | func Run(ctx context.Context, opts Options) error { | |
| 18 | + | log := logging.For(ctx, "app") | |
| 19 | + | log.Info().Str("backend", opts.Backend).Msg("starting nefertty") | |
| 20 | + | if opts.Timeout > 0 { | |
| 21 | + | var cancel context.CancelFunc | |
| 22 | + | ctx, cancel = context.WithTimeout(ctx, opts.Timeout) | |
| 23 | + | defer cancel() | |
| 24 | + | } | |
| 25 | + | <-ctx.Done() | |
| 26 | + | return nil | |
| 27 | + | } | |
| @@ -0,0 +1,27 @@ | |||||
| 1 | + | // Package app coordinates the application lifecycle. | |||
| 2 | + | package app | |||
| 3 | + | ||||
| 4 | + | import ( | |||
| 5 | + | "context" | |||
| 6 | + | "time" | |||
| 7 | + | ||||
| 8 | + | "github.com/bnema/nefertty/internal/logging" | |||
| 9 | + | ) | |||
| 10 | + | ||||
| 11 | + | type Options struct { | |||
| 12 | + | Backend string | |||
| 13 | + | Config string | |||
| 14 | + | Timeout time.Duration | |||
| 15 | + | } | |||
| 16 | + | ||||
| 17 | + | func Run(ctx context.Context, opts Options) error { | |||
| 18 | + | log := logging.For(ctx, "app") | |||
| 19 | + | log.Info().Str("backend", opts.Backend).Msg("starting nefertty") | |||
| 20 | + | if opts.Timeout > 0 { | |||
| 21 | + | var cancel context.CancelFunc | |||
| 22 | + | ctx, cancel = context.WithTimeout(ctx, opts.Timeout) | |||
| 23 | + | defer cancel() | |||
| 24 | + | } | |||
| 25 | + | <-ctx.Done() | |||
| 26 | + | return nil | |||
| 27 | + | } | |||
A internal/core/doc.go +3 -0
| @@ -0,0 +1,3 @@ | |||
| 1 | + | // Package core holds pure layout, focus, and command domain logic. | |
| 2 | + | // It depends only on the standard library and internal/ports. | |
| 3 | + | package core | |
| @@ -0,0 +1,3 @@ | |||||
| 1 | + | // Package core holds pure layout, focus, and command domain logic. | |||
| 2 | + | // It depends only on the standard library and internal/ports. | |||
| 3 | + | package core | |||
A internal/logging/logging.go +98 -0
| @@ -0,0 +1,98 @@ | |||
| 1 | + | // Package logging configures component-scoped zerowrap logging. | |
| 2 | + | package logging | |
| 3 | + | ||
| 4 | + | import ( | |
| 5 | + | "context" | |
| 6 | + | "fmt" | |
| 7 | + | "os" | |
| 8 | + | "path/filepath" | |
| 9 | + | "strings" | |
| 10 | + | ||
| 11 | + | "github.com/bnema/zerowrap" | |
| 12 | + | "github.com/rs/zerolog" | |
| 13 | + | "golang.org/x/term" | |
| 14 | + | ) | |
| 15 | + | ||
| 16 | + | var components = map[string]bool{ | |
| 17 | + | "core": true, "wayland": true, "input": true, "drm": true, | |
| 18 | + | "render": true, "sync": true, "config": true, "app": true, | |
| 19 | + | } | |
| 20 | + | ||
| 21 | + | type debugKey struct{} | |
| 22 | + | type debugSet map[string]bool | |
| 23 | + | ||
| 24 | + | func ParseDebug(value string) (map[string]bool, error) { | |
| 25 | + | selected := make(debugSet) | |
| 26 | + | if value == "" { | |
| 27 | + | return selected, nil | |
| 28 | + | } | |
| 29 | + | for _, name := range strings.Split(value, ",") { | |
| 30 | + | if name == "all" { | |
| 31 | + | selected[name] = true | |
| 32 | + | continue | |
| 33 | + | } | |
| 34 | + | if !components[name] { | |
| 35 | + | return nil, fmt.Errorf("invalid debug component %q", name) | |
| 36 | + | } | |
| 37 | + | selected[name] = true | |
| 38 | + | } | |
| 39 | + | return selected, nil | |
| 40 | + | } | |
| 41 | + | ||
| 42 | + | // Open rotates the previous run's log, opens a fresh JSON log and attaches the logger to ctx. | |
| 43 | + | // The caller must invoke close after all logging is complete. | |
| 44 | + | func Open(ctx context.Context, debug string) (context.Context, func() error, error) { | |
| 45 | + | selected, err := ParseDebug(debug) | |
| 46 | + | if err != nil { | |
| 47 | + | return nil, nil, err | |
| 48 | + | } | |
| 49 | + | state := os.Getenv("XDG_STATE_HOME") | |
| 50 | + | if state == "" { | |
| 51 | + | home, err := os.UserHomeDir() | |
| 52 | + | if err != nil { | |
| 53 | + | return nil, nil, err | |
| 54 | + | } | |
| 55 | + | state = filepath.Join(home, ".local", "state") | |
| 56 | + | } | |
| 57 | + | dir := filepath.Join(state, "nefertty") | |
| 58 | + | if err := os.MkdirAll(dir, 0o700); err != nil { | |
| 59 | + | return nil, nil, err | |
| 60 | + | } | |
| 61 | + | path := filepath.Join(dir, "nefertty.log") | |
| 62 | + | if err := os.Rename(path, path+".1"); err != nil && !os.IsNotExist(err) { | |
| 63 | + | return nil, nil, err | |
| 64 | + | } | |
| 65 | + | file, err := os.OpenFile(path, os.O_CREATE|os.O_TRUNC|os.O_WRONLY, 0o600) | |
| 66 | + | if err != nil { | |
| 67 | + | return nil, nil, err | |
| 68 | + | } | |
| 69 | + | var output = zerowrap.Config{Level: "debug", Format: "json", Output: file} | |
| 70 | + | if term.IsTerminal(int(os.Stderr.Fd())) { | |
| 71 | + | output.Output = &consoleAndFile{file: file} | |
| 72 | + | } | |
| 73 | + | ctx = zerowrap.WithCtx(ctx, zerowrap.New(output)) | |
| 74 | + | ctx = context.WithValue(ctx, debugKey{}, debugSet(selected)) | |
| 75 | + | return ctx, file.Close, nil | |
| 76 | + | } | |
| 77 | + | ||
| 78 | + | type consoleAndFile struct{ file *os.File } | |
| 79 | + | ||
| 80 | + | func (w *consoleAndFile) Write(p []byte) (int, error) { | |
| 81 | + | // JSON remains the canonical output; terminal users also see each entry. | |
| 82 | + | if _, err := os.Stderr.Write(p); err != nil { | |
| 83 | + | return 0, err | |
| 84 | + | } | |
| 85 | + | return w.file.Write(p) | |
| 86 | + | } | |
| 87 | + | ||
| 88 | + | // For returns a component logger with debug enabled only for selected components. | |
| 89 | + | func For(ctx context.Context, component string) zerowrap.Logger { | |
| 90 | + | log := zerowrap.FromCtxWithField(ctx, zerowrap.FieldComponent, component) | |
| 91 | + | selected, _ := ctx.Value(debugKey{}).(debugSet) | |
| 92 | + | level := zerolog.InfoLevel | |
| 93 | + | if selected["all"] || selected[component] { | |
| 94 | + | level = zerolog.DebugLevel | |
| 95 | + | } | |
| 96 | + | log = zerowrap.Logger{Logger: log.Level(level)} | |
| 97 | + | return log | |
| 98 | + | } | |
| @@ -0,0 +1,98 @@ | |||||
| 1 | + | // Package logging configures component-scoped zerowrap logging. | |||
| 2 | + | package logging | |||
| 3 | + | ||||
| 4 | + | import ( | |||
| 5 | + | "context" | |||
| 6 | + | "fmt" | |||
| 7 | + | "os" | |||
| 8 | + | "path/filepath" | |||
| 9 | + | "strings" | |||
| 10 | + | ||||
| 11 | + | "github.com/bnema/zerowrap" | |||
| 12 | + | "github.com/rs/zerolog" | |||
| 13 | + | "golang.org/x/term" | |||
| 14 | + | ) | |||
| 15 | + | ||||
| 16 | + | var components = map[string]bool{ | |||
| 17 | + | "core": true, "wayland": true, "input": true, "drm": true, | |||
| 18 | + | "render": true, "sync": true, "config": true, "app": true, | |||
| 19 | + | } | |||
| 20 | + | ||||
| 21 | + | type debugKey struct{} | |||
| 22 | + | type debugSet map[string]bool | |||
| 23 | + | ||||
| 24 | + | func ParseDebug(value string) (map[string]bool, error) { | |||
| 25 | + | selected := make(debugSet) | |||
| 26 | + | if value == "" { | |||
| 27 | + | return selected, nil | |||
| 28 | + | } | |||
| 29 | + | for _, name := range strings.Split(value, ",") { | |||
| 30 | + | if name == "all" { | |||
| 31 | + | selected[name] = true | |||
| 32 | + | continue | |||
| 33 | + | } | |||
| 34 | + | if !components[name] { | |||
| 35 | + | return nil, fmt.Errorf("invalid debug component %q", name) | |||
| 36 | + | } | |||
| 37 | + | selected[name] = true | |||
| 38 | + | } | |||
| 39 | + | return selected, nil | |||
| 40 | + | } | |||
| 41 | + | ||||
| 42 | + | // Open rotates the previous run's log, opens a fresh JSON log and attaches the logger to ctx. | |||
| 43 | + | // The caller must invoke close after all logging is complete. | |||
| 44 | + | func Open(ctx context.Context, debug string) (context.Context, func() error, error) { | |||
| 45 | + | selected, err := ParseDebug(debug) | |||
| 46 | + | if err != nil { | |||
| 47 | + | return nil, nil, err | |||
| 48 | + | } | |||
| 49 | + | state := os.Getenv("XDG_STATE_HOME") | |||
| 50 | + | if state == "" { | |||
| 51 | + | home, err := os.UserHomeDir() | |||
| 52 | + | if err != nil { | |||
| 53 | + | return nil, nil, err | |||
| 54 | + | } | |||
| 55 | + | state = filepath.Join(home, ".local", "state") | |||
| 56 | + | } | |||
| 57 | + | dir := filepath.Join(state, "nefertty") | |||
| 58 | + | if err := os.MkdirAll(dir, 0o700); err != nil { | |||
| 59 | + | return nil, nil, err | |||
| 60 | + | } | |||
| 61 | + | path := filepath.Join(dir, "nefertty.log") | |||
| 62 | + | if err := os.Rename(path, path+".1"); err != nil && !os.IsNotExist(err) { | |||
| 63 | + | return nil, nil, err | |||
| 64 | + | } | |||
| 65 | + | file, err := os.OpenFile(path, os.O_CREATE|os.O_TRUNC|os.O_WRONLY, 0o600) | |||
| 66 | + | if err != nil { | |||
| 67 | + | return nil, nil, err | |||
| 68 | + | } | |||
| 69 | + | var output = zerowrap.Config{Level: "debug", Format: "json", Output: file} | |||
| 70 | + | if term.IsTerminal(int(os.Stderr.Fd())) { | |||
| 71 | + | output.Output = &consoleAndFile{file: file} | |||
| 72 | + | } | |||
| 73 | + | ctx = zerowrap.WithCtx(ctx, zerowrap.New(output)) | |||
| 74 | + | ctx = context.WithValue(ctx, debugKey{}, debugSet(selected)) | |||
| 75 | + | return ctx, file.Close, nil | |||
| 76 | + | } | |||
| 77 | + | ||||
| 78 | + | type consoleAndFile struct{ file *os.File } | |||
| 79 | + | ||||
| 80 | + | func (w *consoleAndFile) Write(p []byte) (int, error) { | |||
| 81 | + | // JSON remains the canonical output; terminal users also see each entry. | |||
| 82 | + | if _, err := os.Stderr.Write(p); err != nil { | |||
| 83 | + | return 0, err | |||
| 84 | + | } | |||
| 85 | + | return w.file.Write(p) | |||
| 86 | + | } | |||
| 87 | + | ||||
| 88 | + | // For returns a component logger with debug enabled only for selected components. | |||
| 89 | + | func For(ctx context.Context, component string) zerowrap.Logger { | |||
| 90 | + | log := zerowrap.FromCtxWithField(ctx, zerowrap.FieldComponent, component) | |||
| 91 | + | selected, _ := ctx.Value(debugKey{}).(debugSet) | |||
| 92 | + | level := zerolog.InfoLevel | |||
| 93 | + | if selected["all"] || selected[component] { | |||
| 94 | + | level = zerolog.DebugLevel | |||
| 95 | + | } | |||
| 96 | + | log = zerowrap.Logger{Logger: log.Level(level)} | |||
| 97 | + | return log | |||
| 98 | + | } | |||
A internal/logging/logging_test.go +80 -0
| @@ -0,0 +1,80 @@ | |||
| 1 | + | package logging | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "bytes" | |
| 5 | + | "context" | |
| 6 | + | "encoding/json" | |
| 7 | + | "os" | |
| 8 | + | "path/filepath" | |
| 9 | + | "testing" | |
| 10 | + | ) | |
| 11 | + | ||
| 12 | + | func TestParseDebug(t *testing.T) { | |
| 13 | + | for _, tc := range []struct { | |
| 14 | + | input string | |
| 15 | + | wantError bool | |
| 16 | + | selected []string | |
| 17 | + | }{ | |
| 18 | + | {"", false, nil}, {"core,app", false, []string{"core", "app"}}, | |
| 19 | + | {"all", false, []string{"all"}}, {"unknown", true, nil}, {"core,", true, nil}, | |
| 20 | + | } { | |
| 21 | + | got, err := ParseDebug(tc.input) | |
| 22 | + | if (err != nil) != tc.wantError { | |
| 23 | + | t.Fatalf("ParseDebug(%q): %v", tc.input, err) | |
| 24 | + | } | |
| 25 | + | for _, name := range tc.selected { | |
| 26 | + | if !got[name] { | |
| 27 | + | t.Errorf("ParseDebug(%q) missing %s", tc.input, name) | |
| 28 | + | } | |
| 29 | + | } | |
| 30 | + | } | |
| 31 | + | } | |
| 32 | + | ||
| 33 | + | func TestComponentLevelsAndRotation(t *testing.T) { | |
| 34 | + | t.Setenv("XDG_STATE_HOME", t.TempDir()) | |
| 35 | + | path := filepath.Join(os.Getenv("XDG_STATE_HOME"), "nefertty", "nefertty.log") | |
| 36 | + | for run := 0; run < 2; run++ { | |
| 37 | + | ctx, closeLog, err := Open(context.Background(), "core") | |
| 38 | + | if err != nil { | |
| 39 | + | t.Fatal(err) | |
| 40 | + | } | |
| 41 | + | core := For(ctx, "core") | |
| 42 | + | app := For(ctx, "app") | |
| 43 | + | core.Debug().Msg("visible") | |
| 44 | + | app.Debug().Msg("hidden") | |
| 45 | + | app.Info().Msg("startup") | |
| 46 | + | if err := closeLog(); err != nil { | |
| 47 | + | t.Fatal(err) | |
| 48 | + | } | |
| 49 | + | data, err := os.ReadFile(path) | |
| 50 | + | if err != nil { | |
| 51 | + | t.Fatal(err) | |
| 52 | + | } | |
| 53 | + | lines := splitLines(data) | |
| 54 | + | if len(lines) != 2 { | |
| 55 | + | t.Fatalf("expected 2 entries, got %q", data) | |
| 56 | + | } | |
| 57 | + | for _, line := range lines { | |
| 58 | + | var entry map[string]any | |
| 59 | + | if err := json.Unmarshal(line, &entry); err != nil { | |
| 60 | + | t.Fatal(err) | |
| 61 | + | } | |
| 62 | + | if entry["component"] != "core" && entry["component"] != "app" { | |
| 63 | + | t.Fatalf("missing component: %v", entry) | |
| 64 | + | } | |
| 65 | + | } | |
| 66 | + | } | |
| 67 | + | if _, err := os.Stat(path + ".1"); err != nil { | |
| 68 | + | t.Fatal(err) | |
| 69 | + | } | |
| 70 | + | } | |
| 71 | + | ||
| 72 | + | func splitLines(data []byte) [][]byte { | |
| 73 | + | var lines [][]byte | |
| 74 | + | for _, line := range bytes.Split(data, []byte("\n")) { | |
| 75 | + | if len(line) > 0 { | |
| 76 | + | lines = append(lines, line) | |
| 77 | + | } | |
| 78 | + | } | |
| 79 | + | return lines | |
| 80 | + | } | |
| @@ -0,0 +1,80 @@ | |||||
| 1 | + | package logging | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "bytes" | |||
| 5 | + | "context" | |||
| 6 | + | "encoding/json" | |||
| 7 | + | "os" | |||
| 8 | + | "path/filepath" | |||
| 9 | + | "testing" | |||
| 10 | + | ) | |||
| 11 | + | ||||
| 12 | + | func TestParseDebug(t *testing.T) { | |||
| 13 | + | for _, tc := range []struct { | |||
| 14 | + | input string | |||
| 15 | + | wantError bool | |||
| 16 | + | selected []string | |||
| 17 | + | }{ | |||
| 18 | + | {"", false, nil}, {"core,app", false, []string{"core", "app"}}, | |||
| 19 | + | {"all", false, []string{"all"}}, {"unknown", true, nil}, {"core,", true, nil}, | |||
| 20 | + | } { | |||
| 21 | + | got, err := ParseDebug(tc.input) | |||
| 22 | + | if (err != nil) != tc.wantError { | |||
| 23 | + | t.Fatalf("ParseDebug(%q): %v", tc.input, err) | |||
| 24 | + | } | |||
| 25 | + | for _, name := range tc.selected { | |||
| 26 | + | if !got[name] { | |||
| 27 | + | t.Errorf("ParseDebug(%q) missing %s", tc.input, name) | |||
| 28 | + | } | |||
| 29 | + | } | |||
| 30 | + | } | |||
| 31 | + | } | |||
| 32 | + | ||||
| 33 | + | func TestComponentLevelsAndRotation(t *testing.T) { | |||
| 34 | + | t.Setenv("XDG_STATE_HOME", t.TempDir()) | |||
| 35 | + | path := filepath.Join(os.Getenv("XDG_STATE_HOME"), "nefertty", "nefertty.log") | |||
| 36 | + | for run := 0; run < 2; run++ { | |||
| 37 | + | ctx, closeLog, err := Open(context.Background(), "core") | |||
| 38 | + | if err != nil { | |||
| 39 | + | t.Fatal(err) | |||
| 40 | + | } | |||
| 41 | + | core := For(ctx, "core") | |||
| 42 | + | app := For(ctx, "app") | |||
| 43 | + | core.Debug().Msg("visible") | |||
| 44 | + | app.Debug().Msg("hidden") | |||
| 45 | + | app.Info().Msg("startup") | |||
| 46 | + | if err := closeLog(); err != nil { | |||
| 47 | + | t.Fatal(err) | |||
| 48 | + | } | |||
| 49 | + | data, err := os.ReadFile(path) | |||
| 50 | + | if err != nil { | |||
| 51 | + | t.Fatal(err) | |||
| 52 | + | } | |||
| 53 | + | lines := splitLines(data) | |||
| 54 | + | if len(lines) != 2 { | |||
| 55 | + | t.Fatalf("expected 2 entries, got %q", data) | |||
| 56 | + | } | |||
| 57 | + | for _, line := range lines { | |||
| 58 | + | var entry map[string]any | |||
| 59 | + | if err := json.Unmarshal(line, &entry); err != nil { | |||
| 60 | + | t.Fatal(err) | |||
| 61 | + | } | |||
| 62 | + | if entry["component"] != "core" && entry["component"] != "app" { | |||
| 63 | + | t.Fatalf("missing component: %v", entry) | |||
| 64 | + | } | |||
| 65 | + | } | |||
| 66 | + | } | |||
| 67 | + | if _, err := os.Stat(path + ".1"); err != nil { | |||
| 68 | + | t.Fatal(err) | |||
| 69 | + | } | |||
| 70 | + | } | |||
| 71 | + | ||||
| 72 | + | func splitLines(data []byte) [][]byte { | |||
| 73 | + | var lines [][]byte | |||
| 74 | + | for _, line := range bytes.Split(data, []byte("\n")) { | |||
| 75 | + | if len(line) > 0 { | |||
| 76 | + | lines = append(lines, line) | |||
| 77 | + | } | |||
| 78 | + | } | |||
| 79 | + | return lines | |||
| 80 | + | } | |||
A internal/ports/doc.go +2 -0
| @@ -0,0 +1,2 @@ | |||
| 1 | + | // Package ports defines boundary interfaces and channel message types. | |
| 2 | + | package ports | |
| @@ -0,0 +1,2 @@ | |||||
| 1 | + | // Package ports defines boundary interfaces and channel message types. | |||
| 2 | + | package ports | |||