diff --git a/internal/cli/options.go b/internal/cli/options.go new file mode 100644 index 0000000..f27d768 --- /dev/null +++ b/internal/cli/options.go @@ -0,0 +1,16 @@ +package cli + +// Options carries the command-local option values every adapter maps into +// its service request: the raw repository value and its presence, the raw +// group arguments in command-line order, and the verbosity policy. +type Options struct { + Repository string + RepositorySet bool + Groups []string + Verbose bool +} + +// GroupsCopy returns a defensive copy of the group arguments. +func (o Options) GroupsCopy() []string { + return append([]string(nil), o.Groups...) +} diff --git a/internal/cli/options_test.go b/internal/cli/options_test.go new file mode 100644 index 0000000..f04d685 --- /dev/null +++ b/internal/cli/options_test.go @@ -0,0 +1,53 @@ +package cli + +import ( + "strings" + "testing" +) + +func TestCLIOptions(t *testing.T) { + scenarios := []struct { + name string + run func(*testing.T) + }{ + {"explicit presence", testOptionsPresence}, + {"interspersed groups", testOptionsInterspersed}, + {"defensive copies", testOptionsCopies}, + } + for _, scenario := range scenarios { + t.Run(scenario.name, scenario.run) + } +} + +func testOptionsPresence(t *testing.T) { + var zero Options + if zero.RepositorySet { + t.Fatal("zero Options must leave presence false") + } + options := Options{Repository: "repo", RepositorySet: true, Verbose: true} + if !options.RepositorySet || options.Repository != "repo" || !options.Verbose { + t.Fatalf("options = %+v, want the explicit values preserved", options) + } +} + +func testOptionsInterspersed(t *testing.T) { + options := Options{Groups: []string{"first", "middle", "last"}, Repository: "repo", RepositorySet: true} + if strings.Join(options.Groups, ",") != "first,middle,last" { + t.Fatalf("groups = %v, want the raw order preserved", options.Groups) + } + if !options.RepositorySet { + t.Fatal("a flag after group arguments must still set presence") + } +} + +func testOptionsCopies(t *testing.T) { + options := Options{Groups: []string{"a", "b"}} + copied := options.GroupsCopy() + copied[0] = "mutated" + if options.Groups[0] != "a" { + t.Fatal("mutating a GroupsCopy must not reach the options") + } + if len(options.GroupsCopy()) != 2 { + t.Fatal("GroupsCopy must preserve the argument count") + } +} diff --git a/internal/cli/runtime.go b/internal/cli/runtime.go new file mode 100644 index 0000000..c4725a3 --- /dev/null +++ b/internal/cli/runtime.go @@ -0,0 +1,103 @@ +// Package cli implements the Cobra adapters of `cattery` (PLAN.md Section +// 11): every command mechanically maps raw values into one injected +// application service call and renders its typed result. Cobra and x/term +// stay confined to this package. +package cli + +import ( + "io" + "os" + + "golang.org/x/term" +) + +// Streams bundles the process streams one application renders to. +type Streams struct { + Stdin io.Reader + Stdout io.Writer + Stderr io.Writer +} + +// RuntimeInput carries the injected process-boundary values of one +// application. +type RuntimeInput struct { + Streams Streams + WorkingDir string + Environment []string + IsTerminal func(fd int) bool + SetVerbose func(bool) +} + +// Runtime carries the injected process-boundary values of one application: +// the streams, working directory, environment, terminal predicate, and the +// per-application verbosity callback (PLAN.md Section 12.1). Instances +// never share mutable state. +type Runtime struct { + stdin io.Reader + stdout io.Writer + stderr io.Writer + workingDir string + environment []string + isTerminal func(fd int) bool + setVerbose func(bool) +} + +// NewRuntime freezes one runtime, defaulting the streams and the terminal +// predicate and copying the environment. +func NewRuntime(input RuntimeInput) Runtime { + stdin, stdout, stderr := input.Streams.Stdin, input.Streams.Stdout, input.Streams.Stderr + if stdin == nil { + stdin = os.Stdin + } + if stdout == nil { + stdout = os.Stdout + } + if stderr == nil { + stderr = os.Stderr + } + isTerminal := input.IsTerminal + if isTerminal == nil { + isTerminal = term.IsTerminal + } + return Runtime{ + stdin: stdin, + stdout: stdout, + stderr: stderr, + workingDir: input.WorkingDir, + environment: append([]string(nil), input.Environment...), + isTerminal: isTerminal, + setVerbose: input.SetVerbose, + } +} + +// Stdin returns the injected standard input. +func (r Runtime) Stdin() io.Reader { return r.stdin } + +// Stdout returns the injected standard output. +func (r Runtime) Stdout() io.Writer { return r.stdout } + +// Stderr returns the injected standard error. +func (r Runtime) Stderr() io.Writer { return r.stderr } + +// WorkingDir returns the injected current directory. +func (r Runtime) WorkingDir() string { return r.workingDir } + +// Environment returns a defensive copy of the injected environment. +func (r Runtime) Environment() []string { + return append([]string(nil), r.environment...) +} + +// IsTerminal reports whether the given file descriptor is a terminal. +func (r Runtime) IsTerminal(fd int) bool { + if r.isTerminal == nil { + return false + } + return r.isTerminal(fd) +} + +// SetVerbose applies the per-application verbosity level, if wired. +func (r Runtime) SetVerbose(verbose bool) { + if r.setVerbose != nil { + r.setVerbose(verbose) + } +} diff --git a/internal/cli/runtime_test.go b/internal/cli/runtime_test.go new file mode 100644 index 0000000..2f16142 --- /dev/null +++ b/internal/cli/runtime_test.go @@ -0,0 +1,87 @@ +package cli + +import ( + "bytes" + "strings" + "testing" +) + +func TestCLIRuntime(t *testing.T) { + scenarios := []struct { + name string + run func(*testing.T) + }{ + {"stream defaults", testRuntimeStreams}, + {"terminal predicates", testRuntimeTerminal}, + {"verbosity callback", testRuntimeVerbose}, + {"instance isolation", testRuntimeIsolation}, + } + for _, scenario := range scenarios { + t.Run(scenario.name, scenario.run) + } +} + +// runtimeFixture assembles one runtime over isolated buffers. +func runtimeFixture(t *testing.T, environment []string, isTerminal func(int) bool) (Runtime, *bytes.Buffer, *bytes.Buffer) { + t.Helper() + stdout := &bytes.Buffer{} + stderr := &bytes.Buffer{} + runtime := NewRuntime(RuntimeInput{ + Streams: Streams{Stdin: strings.NewReader("input"), Stdout: stdout, Stderr: stderr}, + WorkingDir: "/work", Environment: environment, IsTerminal: isTerminal, + }) + return runtime, stdout, stderr +} + +func testRuntimeStreams(t *testing.T) { + runtime, stdout, stderr := runtimeFixture(t, []string{"A=1"}, nil) + if runtime.WorkingDir() != "/work" { + t.Fatalf("working dir = %q, want /work", runtime.WorkingDir()) + } + if _, err := runtime.Stdout().Write([]byte("out")); err != nil { + t.Fatal(err) + } + if _, err := runtime.Stderr().Write([]byte("err")); err != nil { + t.Fatal(err) + } + if stdout.String() != "out" || stderr.String() != "err" { + t.Fatalf("streams = %q %q, want routed output", stdout.String(), stderr.String()) + } + environment := runtime.Environment() + if len(environment) != 1 || environment[0] != "A=1" { + t.Fatalf("environment = %v, want the injected copy", environment) + } +} + +func testRuntimeTerminal(t *testing.T) { + runtime, _, _ := runtimeFixture(t, nil, func(fd int) bool { return fd == 0 }) + if !runtime.IsTerminal(0) || runtime.IsTerminal(1) { + t.Fatal("the injected terminal predicate must distinguish descriptors") + } + // A nil predicate defaults to the x/term binding. + runtime, _, _ = runtimeFixture(t, nil, nil) + if runtime.IsTerminal(3) { + t.Fatal("an unmapped descriptor must not be a terminal") + } +} + +func testRuntimeVerbose(t *testing.T) { + levels := []bool{} + runtime, _, _ := runtimeFixture(t, nil, nil) + runtime.setVerbose = func(verbose bool) { levels = append(levels, verbose) } + runtime.SetVerbose(true) + runtime.SetVerbose(false) + if len(levels) != 2 || levels[0] != true || levels[1] != false { + t.Fatalf("levels = %v, want true then false", levels) + } +} + +func testRuntimeIsolation(t *testing.T) { + first, _, _ := runtimeFixture(t, []string{"A=1", "B=2"}, nil) + second, _, _ := runtimeFixture(t, []string{"C=3"}, nil) + environment := first.Environment() + environment[0] = "mutated" + if first.Environment()[0] != "A=1" || second.Environment()[0] != "C=3" { + t.Fatal("runtimes must never share environment state") + } +}