Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
57 changes: 57 additions & 0 deletions cli/cmd/same/commands/commands_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -331,3 +331,60 @@ func TestCleanCmd_All(t *testing.T) {
t.Errorf("Expected environment cache directory to be removed, but it still exists")
}
}

func TestRun_OutputModeFlags(t *testing.T) {
tests := []struct {
name string
args []string
}{
{
name: "output-mode flag",
args: []string{"run", "--output-mode=linear", "build"},
},
{
name: "ci flag",
args: []string{"run", "--ci", "build"},
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
ctrl := gomock.NewController(t)
defer ctrl.Finish()

mockLoader := mocks.NewMockConfigLoader(ctrl)
mockExecutor := mocks.NewMockExecutor(ctrl)
mockStore := mocks.NewMockBuildInfoStore(ctrl)
mockHasher := mocks.NewMockHasher(ctrl)
mockResolver := mocks.NewMockInputResolver(ctrl)
mockEnvFactory := mocks.NewMockEnvironmentFactory(ctrl)

g := domain.NewGraph()
g.SetRoot(".")
buildTask := &domain.Task{Name: domain.NewInternedString("build"), WorkingDir: domain.NewInternedString("Root")}
_ = g.AddTask(buildTask)

mockLogger := mocks.NewMockLogger(ctrl)
a := app.New(mockLoader, mockExecutor, mockLogger, mockStore, mockHasher, mockResolver, mockEnvFactory).
WithTeaOptions(tea.WithInput(nil), tea.WithOutput(io.Discard))

cli := commands.New(a)

mockLoader.EXPECT().Load(".").Return(g, nil).Times(1)
mockResolver.EXPECT().ResolveInputs(gomock.Any(), ".").Return([]string{}, nil).Times(1)
mockHasher.EXPECT().ComputeInputHash(gomock.Any(), gomock.Any(), gomock.Any()).Return("hash123", nil).Times(1)
mockStore.EXPECT().Get("build").Return(nil, nil).Times(1)
mockExecutor.EXPECT().Execute(
gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(),
).Return(nil).Times(1)
mockStore.EXPECT().Put(gomock.Any()).Return(nil).Times(1)

cli.SetArgs(tt.args)

err := cli.Execute(context.Background())
if err != nil {
t.Errorf("Expected no error, got: %v", err)
}
})
}
}
15 changes: 13 additions & 2 deletions cli/cmd/same/commands/run.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,13 +18,24 @@ func (c *CLI) newRunCmd() *cobra.Command {
}
noCache, _ := cmd.Flags().GetBool("no-cache")
inspect, _ := cmd.Flags().GetBool("inspect")
outputMode, _ := cmd.Flags().GetString("output-mode")
ci, _ := cmd.Flags().GetBool("ci")

// If --ci is set, override output-mode to "linear"
if ci {
outputMode = "linear"
}

return c.app.Run(cmd.Context(), args, app.RunOptions{
NoCache: noCache,
Inspect: inspect,
NoCache: noCache,
Inspect: inspect,
OutputMode: outputMode,
})
},
}
cmd.Flags().BoolP("no-cache", "n", false, "Bypass the build cache and force execution")
cmd.Flags().BoolP("inspect", "i", false, "Inspect the TUI after build completion (prevents auto-exit)")
cmd.Flags().StringP("output-mode", "o", "auto", "Output mode: auto, tui, or linear")
cmd.Flags().Bool("ci", false, "Use linear output mode (shorthand for --output-mode=linear)")
return cmd
}
3 changes: 2 additions & 1 deletion cli/go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,8 @@ require (
go.opentelemetry.io/auto/sdk v1.2.1 // indirect
go.opentelemetry.io/otel/metric v1.39.0 // indirect
golang.org/x/mod v0.31.0 // indirect
golang.org/x/sys v0.39.0 // indirect
golang.org/x/sys v0.40.0 // indirect
golang.org/x/term v0.39.0 // indirect
golang.org/x/text v0.3.8 // indirect
golang.org/x/tools v0.40.0 // indirect
)
4 changes: 4 additions & 0 deletions cli/go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,10 @@ golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBc
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.39.0 h1:CvCKL8MeisomCi6qNZ+wbb0DN9E5AATixKsvNtMoMFk=
golang.org/x/sys v0.39.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/sys v0.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ=
golang.org/x/sys v0.40.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/term v0.39.0 h1:RclSuaJf32jOqZz74CkPA9qFuVTX7vhLlpfj/IGWlqY=
golang.org/x/term v0.39.0/go.mod h1:yxzUCTP/U+FzoxfdKmLaA0RV1WgE0VY7hXBwKtY/4ww=
golang.org/x/text v0.3.8 h1:nAL+RVCQ9uMn3vJZbV+MRnydTJFPf8qqY42YiA6MrqY=
golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ=
golang.org/x/tools v0.40.0 h1:yLkxfA+Qnul4cs9QA3KnlFu0lVmd8JJfoq+E41uSutA=
Expand Down
49 changes: 49 additions & 0 deletions cli/internal/adapters/detector/env.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
// Package detector provides environment detection for output mode selection.
package detector

import (
"os"

"golang.org/x/term"
)

// OutputMode represents the rendering mode for the application.
type OutputMode int

const (
// ModeAuto automatically detects the appropriate mode.
ModeAuto OutputMode = iota
// ModeTUI forces the interactive TUI renderer.
ModeTUI
// ModeLinear forces the linear CI renderer.
ModeLinear
)

// DetectEnvironment returns the recommended output mode based on the environment.
// It checks if stdout is a TTY and if CI environment variables are set.
func DetectEnvironment() OutputMode {
isTTY := term.IsTerminal(int(os.Stdout.Fd()))

ci := os.Getenv("CI")
isCI := ci == "true" || ci == "1"

if !isTTY || isCI {
return ModeLinear
}
return ModeTUI
}

// ResolveMode applies user override flag to auto-detection.
// userFlag should be one of: "auto", "tui", "linear", "ci", or empty.
func ResolveMode(autoDetected OutputMode, userFlag string) OutputMode {
switch userFlag {
case "tui":
return ModeTUI
case "linear", "ci":
return ModeLinear
case "auto", "":
return autoDetected
default:
return autoDetected
}
}
160 changes: 160 additions & 0 deletions cli/internal/adapters/detector/env_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,160 @@
package detector_test

import (
"os"
"testing"

"go.trai.ch/same/internal/adapters/detector"
)

func TestDetectEnvironment(t *testing.T) {
tests := []struct {
name string
ciValue string
expected detector.OutputMode
}{
{
name: "CI=true forces linear mode",
ciValue: "true",
expected: detector.ModeLinear,
},
{
name: "CI=1 forces linear mode",
ciValue: "1",
expected: detector.ModeLinear,
},
{
name: "CI=false does not force linear",
ciValue: "false",
expected: detector.ModeAuto,
},
{
name: "No CI env var",
ciValue: "",
expected: detector.ModeAuto,
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
originalCI := os.Getenv("CI")
defer func() {
if originalCI != "" {
_ = os.Setenv("CI", originalCI)
} else {
_ = os.Unsetenv("CI")
}
}()

if tt.ciValue != "" {
if err := os.Setenv("CI", tt.ciValue); err != nil {
t.Fatalf("Failed to set CI: %v", err)
}
} else {
_ = os.Unsetenv("CI")
}

mode := detector.DetectEnvironment()

if tt.ciValue == "true" || tt.ciValue == "1" {
if mode != detector.ModeLinear {
t.Errorf("Expected ModeLinear with CI=%s, got %v", tt.ciValue, mode)
}
}
})
}
}

func TestResolveMode(t *testing.T) {
tests := []struct {
name string
autoDetected detector.OutputMode
userFlag string
expected detector.OutputMode
}{
{
name: "auto respects auto-detection (TUI)",
autoDetected: detector.ModeTUI,
userFlag: "auto",
expected: detector.ModeTUI,
},
{
name: "auto respects auto-detection (Linear)",
autoDetected: detector.ModeLinear,
userFlag: "auto",
expected: detector.ModeLinear,
},
{
name: "empty flag respects auto-detection",
autoDetected: detector.ModeTUI,
userFlag: "",
expected: detector.ModeTUI,
},
{
name: "tui overrides auto-detection",
autoDetected: detector.ModeLinear,
userFlag: "tui",
expected: detector.ModeTUI,
},
{
name: "linear overrides auto-detection",
autoDetected: detector.ModeTUI,
userFlag: "linear",
expected: detector.ModeLinear,
},
{
name: "ci is alias for linear",
autoDetected: detector.ModeTUI,
userFlag: "ci",
expected: detector.ModeLinear,
},
{
name: "invalid flag respects auto-detection",
autoDetected: detector.ModeTUI,
userFlag: "invalid",
expected: detector.ModeTUI,
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := detector.ResolveMode(tt.autoDetected, tt.userFlag)
if got != tt.expected {
t.Errorf("ResolveMode(%v, %q) = %v, want %v",
tt.autoDetected, tt.userFlag, got, tt.expected)
}
})
}
}

func TestResolveMode_EdgeCases(t *testing.T) {
tests := []struct {
name string
autoDetected detector.OutputMode
userFlag string
expected detector.OutputMode
}{
{
name: "unknown flag falls back to auto-detection (Linear)",
autoDetected: detector.ModeLinear,
userFlag: "unknown",
expected: detector.ModeLinear,
},
{
name: "empty string falls back to auto-detection (Linear)",
autoDetected: detector.ModeLinear,
userFlag: "",
expected: detector.ModeLinear,
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := detector.ResolveMode(tt.autoDetected, tt.userFlag)
if got != tt.expected {
t.Errorf("ResolveMode(%v, %q) = %v, want %v",
tt.autoDetected, tt.userFlag, got, tt.expected)
}
})
}
}
10 changes: 10 additions & 0 deletions cli/internal/adapters/detector/node.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
package detector

// Node is the graft node for dependency injection.
// The detector has no dependencies.
type Node struct{}

// NewNode creates a new Node.
func NewNode() *Node {
return &Node{}
}
15 changes: 15 additions & 0 deletions cli/internal/adapters/linear/node.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
package linear

// Node is the graft node for dependency injection.
// The linear renderer has no dependencies.
type Node struct{}

// NewNode creates a new Node.
func NewNode() *Node {
return &Node{}
}

// Renderer returns a new LinearRenderer with stdout and stderr.
func (n *Node) Renderer() *Renderer {
return NewRenderer(nil, nil)
}
22 changes: 22 additions & 0 deletions cli/internal/adapters/linear/node_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
package linear_test

import (
"testing"

"go.trai.ch/same/internal/adapters/linear"
)

func TestNode_NewNode(t *testing.T) {
node := linear.NewNode()
if node == nil {
t.Fatal("Expected non-nil node")
}
}

func TestNode_Renderer(t *testing.T) {
node := linear.NewNode()
renderer := node.Renderer()
if renderer == nil {
t.Fatal("Expected non-nil renderer")
}
}
Loading