detached f418944

feat(infrastructure): implement adapters for all domain ports with comprehensive tests

commit
f4189441c7cdfd7ef895e5628466c9e2c654291e
author
bnema · · 2025-10-31 13:53 UTC
parent
8f279e5
signature
GPG Unverified2B82207D370127A0 The key is unknown
14 files · +1831 -0
A internal/infrastructure/executor/chroot.go +93 -0
@@ -0,0 +1,93 @@
1+package executor
2+
3+import (
4+ "bytes"
5+ "context"
6+ "fmt"
7+ "os"
8+ "os/exec"
9+ "path/filepath"
10+)
11+
12+// ChrootExecutor implements the ChrootExecutor port using chroot commands
13+type ChrootExecutor struct {
14+ cmdExecutor *ShellExecutor
15+}
16+
17+// NewChrootExecutor creates a new chroot executor
18+func NewChrootExecutor() *ChrootExecutor {
19+ return &ChrootExecutor{
20+ cmdExecutor: NewShellExecutor(),
21+ }
22+}
23+
24+// ExecuteInChroot runs a command inside a chroot environment
25+func (ce *ChrootExecutor) ExecuteInChroot(ctx context.Context, chrootPath string, command string, args ...string) ([]byte, error) {
26+ // Verify chroot path exists
27+ if _, err := os.Stat(chrootPath); err != nil {
28+ return nil, fmt.Errorf("chroot path does not exist: %w", err)
29+ }
30+
31+ // Build chroot command: arch-chroot <path> <command> <args...>
32+ allArgs := append([]string{chrootPath, command}, args...)
33+ cmd := exec.CommandContext(ctx, "arch-chroot", allArgs...)
34+
35+ var stdout, stderr bytes.Buffer
36+ cmd.Stdout = &stdout
37+ cmd.Stderr = &stderr
38+
39+ if err := cmd.Run(); err != nil {
40+ return nil, fmt.Errorf("chroot command failed: %w (stderr: %s)", err, stderr.String())
41+ }
42+
43+ return stdout.Bytes(), nil
44+}
45+
46+// ChrootSystemctl runs systemctl commands in chroot
47+// This is a convenience method that ensures proper logging of systemctl operations
48+func (ce *ChrootExecutor) ChrootSystemctl(logPath string, chrootPath string, args ...string) error {
49+ // Verify paths exist
50+ if _, err := os.Stat(chrootPath); err != nil {
51+ return fmt.Errorf("chroot path does not exist: %w", err)
52+ }
53+
54+ if logPath != "" {
55+ if err := os.MkdirAll(filepath.Dir(logPath), 0755); err != nil {
56+ return fmt.Errorf("failed to create log directory: %w", err)
57+ }
58+ }
59+
60+ // Build chroot command with systemctl
61+ allArgs := append([]string{chrootPath, "systemctl"}, args...)
62+ cmd := exec.Command("arch-chroot", allArgs...)
63+
64+ var stdout, stderr bytes.Buffer
65+ cmd.Stdout = &stdout
66+ cmd.Stderr = &stderr
67+
68+ if err := cmd.Run(); err != nil {
69+ errMsg := fmt.Sprintf("chroot systemctl failed: %v (stderr: %s)", err, stderr.String())
70+
71+ // Log to file if specified
72+ if logPath != "" {
73+ logFile, err := os.OpenFile(logPath, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0644)
74+ if err == nil {
75+ defer logFile.Close()
76+ logFile.WriteString(errMsg + "\n")
77+ }
78+ }
79+
80+ return fmt.Errorf("%s", errMsg)
81+ }
82+
83+ // Log success to file if specified
84+ if logPath != "" {
85+ logFile, err := os.OpenFile(logPath, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0644)
86+ if err == nil {
87+ defer logFile.Close()
88+ logFile.WriteString(fmt.Sprintf("systemctl %v executed successfully\n", args))
89+ }
90+ }
91+
92+ return nil
93+}
@@ -0,0 +1,93 @@
1+package executor
2+
3+import (
4+ "bytes"
5+ "context"
6+ "fmt"
7+ "os"
8+ "os/exec"
9+ "path/filepath"
10+)
11+
12+// ChrootExecutor implements the ChrootExecutor port using chroot commands
13+type ChrootExecutor struct {
14+ cmdExecutor *ShellExecutor
15+}
16+
17+// NewChrootExecutor creates a new chroot executor
18+func NewChrootExecutor() *ChrootExecutor {
19+ return &ChrootExecutor{
20+ cmdExecutor: NewShellExecutor(),
21+ }
22+}
23+
24+// ExecuteInChroot runs a command inside a chroot environment
25+func (ce *ChrootExecutor) ExecuteInChroot(ctx context.Context, chrootPath string, command string, args ...string) ([]byte, error) {
26+ // Verify chroot path exists
27+ if _, err := os.Stat(chrootPath); err != nil {
28+ return nil, fmt.Errorf("chroot path does not exist: %w", err)
29+ }
30+
31+ // Build chroot command: arch-chroot <path> <command> <args...>
32+ allArgs := append([]string{chrootPath, command}, args...)
33+ cmd := exec.CommandContext(ctx, "arch-chroot", allArgs...)
34+
35+ var stdout, stderr bytes.Buffer
36+ cmd.Stdout = &stdout
37+ cmd.Stderr = &stderr
38+
39+ if err := cmd.Run(); err != nil {
40+ return nil, fmt.Errorf("chroot command failed: %w (stderr: %s)", err, stderr.String())
41+ }
42+
43+ return stdout.Bytes(), nil
44+}
45+
46+// ChrootSystemctl runs systemctl commands in chroot
47+// This is a convenience method that ensures proper logging of systemctl operations
48+func (ce *ChrootExecutor) ChrootSystemctl(logPath string, chrootPath string, args ...string) error {
49+ // Verify paths exist
50+ if _, err := os.Stat(chrootPath); err != nil {
51+ return fmt.Errorf("chroot path does not exist: %w", err)
52+ }
53+
54+ if logPath != "" {
55+ if err := os.MkdirAll(filepath.Dir(logPath), 0755); err != nil {
56+ return fmt.Errorf("failed to create log directory: %w", err)
57+ }
58+ }
59+
60+ // Build chroot command with systemctl
61+ allArgs := append([]string{chrootPath, "systemctl"}, args...)
62+ cmd := exec.Command("arch-chroot", allArgs...)
63+
64+ var stdout, stderr bytes.Buffer
65+ cmd.Stdout = &stdout
66+ cmd.Stderr = &stderr
67+
68+ if err := cmd.Run(); err != nil {
69+ errMsg := fmt.Sprintf("chroot systemctl failed: %v (stderr: %s)", err, stderr.String())
70+
71+ // Log to file if specified
72+ if logPath != "" {
73+ logFile, err := os.OpenFile(logPath, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0644)
74+ if err == nil {
75+ defer logFile.Close()
76+ logFile.WriteString(errMsg + "\n")
77+ }
78+ }
79+
80+ return fmt.Errorf("%s", errMsg)
81+ }
82+
83+ // Log success to file if specified
84+ if logPath != "" {
85+ logFile, err := os.OpenFile(logPath, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0644)
86+ if err == nil {
87+ defer logFile.Close()
88+ logFile.WriteString(fmt.Sprintf("systemctl %v executed successfully\n", args))
89+ }
90+ }
91+
92+ return nil
93+}
A internal/infrastructure/executor/chroot_test.go +91 -0
@@ -0,0 +1,91 @@
1+package executor
2+
3+import (
4+ "context"
5+ "os"
6+ "path/filepath"
7+ "testing"
8+)
9+
10+func TestChrootExecutor_NewChrootExecutor(t *testing.T) {
11+ executor := NewChrootExecutor()
12+ if executor == nil {
13+ t.Fatal("expected non-nil executor")
14+ }
15+
16+ if executor.cmdExecutor == nil {
17+ t.Fatal("expected non-nil cmdExecutor")
18+ }
19+}
20+
21+func TestChrootExecutor_ExecuteInChroot_PathNotExists(t *testing.T) {
22+ executor := NewChrootExecutor()
23+ ctx := context.Background()
24+
25+ _, err := executor.ExecuteInChroot(ctx, "/nonexistent/path", "echo", "test")
26+ if err == nil {
27+ t.Fatal("expected error for nonexistent chroot path")
28+ }
29+}
30+
31+func TestChrootExecutor_ExecuteInChroot_CommandNotFound(t *testing.T) {
32+ executor := NewChrootExecutor()
33+ ctx := context.Background()
34+
35+ // Create temporary directory to use as chroot
36+ tmpDir := t.TempDir()
37+
38+ // arch-chroot requires proper setup which we can't do in unit tests,
39+ // so we expect this to fail when trying to execute arch-chroot
40+ _, err := executor.ExecuteInChroot(ctx, tmpDir, "echo", "test")
41+ if err == nil {
42+ t.Fatal("expected error when executing in non-functional chroot")
43+ }
44+}
45+
46+func TestChrootExecutor_ChrootSystemctl_PathNotExists(t *testing.T) {
47+ executor := NewChrootExecutor()
48+
49+ err := executor.ChrootSystemctl("", "/nonexistent/path", "enable", "service")
50+ if err == nil {
51+ t.Fatal("expected error for nonexistent chroot path")
52+ }
53+}
54+
55+func TestChrootExecutor_ChrootSystemctl_CreatesLogDirectory(t *testing.T) {
56+ executor := NewChrootExecutor()
57+ tmpDir := t.TempDir()
58+ logPath := filepath.Join(tmpDir, "subdir", "log.txt")
59+
60+ // This will fail due to arch-chroot not being available,
61+ // but the directory should be created
62+ executor.ChrootSystemctl(logPath, tmpDir, "enable", "service")
63+
64+ // Check if log directory was created
65+ logDir := filepath.Dir(logPath)
66+ if _, err := os.Stat(logDir); os.IsNotExist(err) {
67+ t.Error("expected log directory to be created")
68+ }
69+}
70+
71+func TestChrootExecutor_ChrootSystemctl_LogsErrors(t *testing.T) {
72+ executor := NewChrootExecutor()
73+ tmpDir := t.TempDir()
74+ logPath := filepath.Join(tmpDir, "log.txt")
75+
76+ // This will fail due to arch-chroot not being available
77+ executor.ChrootSystemctl(logPath, tmpDir, "enable", "service")
78+
79+ // Check if log file was created
80+ if _, err := os.Stat(logPath); os.IsNotExist(err) {
81+ // It's OK if log file wasn't created due to error
82+ }
83+}
84+
85+func TestChrootExecutor_ChrootSystemctl_EmptyLogPath(t *testing.T) {
86+ executor := NewChrootExecutor()
87+ tmpDir := t.TempDir()
88+
89+ // This should not panic even with empty log path
90+ _ = executor.ChrootSystemctl("", tmpDir, "enable", "service")
91+}
@@ -0,0 +1,91 @@
1+package executor
2+
3+import (
4+ "context"
5+ "os"
6+ "path/filepath"
7+ "testing"
8+)
9+
10+func TestChrootExecutor_NewChrootExecutor(t *testing.T) {
11+ executor := NewChrootExecutor()
12+ if executor == nil {
13+ t.Fatal("expected non-nil executor")
14+ }
15+
16+ if executor.cmdExecutor == nil {
17+ t.Fatal("expected non-nil cmdExecutor")
18+ }
19+}
20+
21+func TestChrootExecutor_ExecuteInChroot_PathNotExists(t *testing.T) {
22+ executor := NewChrootExecutor()
23+ ctx := context.Background()
24+
25+ _, err := executor.ExecuteInChroot(ctx, "/nonexistent/path", "echo", "test")
26+ if err == nil {
27+ t.Fatal("expected error for nonexistent chroot path")
28+ }
29+}
30+
31+func TestChrootExecutor_ExecuteInChroot_CommandNotFound(t *testing.T) {
32+ executor := NewChrootExecutor()
33+ ctx := context.Background()
34+
35+ // Create temporary directory to use as chroot
36+ tmpDir := t.TempDir()
37+
38+ // arch-chroot requires proper setup which we can't do in unit tests,
39+ // so we expect this to fail when trying to execute arch-chroot
40+ _, err := executor.ExecuteInChroot(ctx, tmpDir, "echo", "test")
41+ if err == nil {
42+ t.Fatal("expected error when executing in non-functional chroot")
43+ }
44+}
45+
46+func TestChrootExecutor_ChrootSystemctl_PathNotExists(t *testing.T) {
47+ executor := NewChrootExecutor()
48+
49+ err := executor.ChrootSystemctl("", "/nonexistent/path", "enable", "service")
50+ if err == nil {
51+ t.Fatal("expected error for nonexistent chroot path")
52+ }
53+}
54+
55+func TestChrootExecutor_ChrootSystemctl_CreatesLogDirectory(t *testing.T) {
56+ executor := NewChrootExecutor()
57+ tmpDir := t.TempDir()
58+ logPath := filepath.Join(tmpDir, "subdir", "log.txt")
59+
60+ // This will fail due to arch-chroot not being available,
61+ // but the directory should be created
62+ executor.ChrootSystemctl(logPath, tmpDir, "enable", "service")
63+
64+ // Check if log directory was created
65+ logDir := filepath.Dir(logPath)
66+ if _, err := os.Stat(logDir); os.IsNotExist(err) {
67+ t.Error("expected log directory to be created")
68+ }
69+}
70+
71+func TestChrootExecutor_ChrootSystemctl_LogsErrors(t *testing.T) {
72+ executor := NewChrootExecutor()
73+ tmpDir := t.TempDir()
74+ logPath := filepath.Join(tmpDir, "log.txt")
75+
76+ // This will fail due to arch-chroot not being available
77+ executor.ChrootSystemctl(logPath, tmpDir, "enable", "service")
78+
79+ // Check if log file was created
80+ if _, err := os.Stat(logPath); os.IsNotExist(err) {
81+ // It's OK if log file wasn't created due to error
82+ }
83+}
84+
85+func TestChrootExecutor_ChrootSystemctl_EmptyLogPath(t *testing.T) {
86+ executor := NewChrootExecutor()
87+ tmpDir := t.TempDir()
88+
89+ // This should not panic even with empty log path
90+ _ = executor.ChrootSystemctl("", tmpDir, "enable", "service")
91+}
A internal/infrastructure/executor/script.go +43 -0
@@ -0,0 +1,43 @@
1+package executor
2+
3+import (
4+ "bytes"
5+ "context"
6+ "fmt"
7+ "os"
8+ "os/exec"
9+)
10+
11+// ScriptExecutor implements the ScriptExecutor port for shell scripts
12+type ScriptExecutor struct{}
13+
14+// NewScriptExecutor creates a new script executor
15+func NewScriptExecutor() *ScriptExecutor {
16+ return &ScriptExecutor{}
17+}
18+
19+// ExecuteScript runs a shell script with environment variables
20+func (se *ScriptExecutor) ExecuteScript(ctx context.Context, scriptPath string, env map[string]string) error {
21+ // Verify script exists
22+ if _, err := os.Stat(scriptPath); err != nil {
23+ return fmt.Errorf("script not found: %w", err)
24+ }
25+
26+ cmd := exec.CommandContext(ctx, "bash", scriptPath)
27+ var stdout, stderr bytes.Buffer
28+ cmd.Stdout = &stdout
29+ cmd.Stderr = &stderr
30+
31+ // Build environment: start with current environment, then add/override with provided env
32+ cmdEnv := os.Environ()
33+ for key, value := range env {
34+ cmdEnv = append(cmdEnv, fmt.Sprintf("%s=%s", key, value))
35+ }
36+ cmd.Env = cmdEnv
37+
38+ if err := cmd.Run(); err != nil {
39+ return fmt.Errorf("script execution failed: %w (stdout: %s, stderr: %s)", err, stdout.String(), stderr.String())
40+ }
41+
42+ return nil
43+}
@@ -0,0 +1,43 @@
1+package executor
2+
3+import (
4+ "bytes"
5+ "context"
6+ "fmt"
7+ "os"
8+ "os/exec"
9+)
10+
11+// ScriptExecutor implements the ScriptExecutor port for shell scripts
12+type ScriptExecutor struct{}
13+
14+// NewScriptExecutor creates a new script executor
15+func NewScriptExecutor() *ScriptExecutor {
16+ return &ScriptExecutor{}
17+}
18+
19+// ExecuteScript runs a shell script with environment variables
20+func (se *ScriptExecutor) ExecuteScript(ctx context.Context, scriptPath string, env map[string]string) error {
21+ // Verify script exists
22+ if _, err := os.Stat(scriptPath); err != nil {
23+ return fmt.Errorf("script not found: %w", err)
24+ }
25+
26+ cmd := exec.CommandContext(ctx, "bash", scriptPath)
27+ var stdout, stderr bytes.Buffer
28+ cmd.Stdout = &stdout
29+ cmd.Stderr = &stderr
30+
31+ // Build environment: start with current environment, then add/override with provided env
32+ cmdEnv := os.Environ()
33+ for key, value := range env {
34+ cmdEnv = append(cmdEnv, fmt.Sprintf("%s=%s", key, value))
35+ }
36+ cmd.Env = cmdEnv
37+
38+ if err := cmd.Run(); err != nil {
39+ return fmt.Errorf("script execution failed: %w (stdout: %s, stderr: %s)", err, stdout.String(), stderr.String())
40+ }
41+
42+ return nil
43+}
A internal/infrastructure/executor/script_test.go +149 -0
@@ -0,0 +1,149 @@
1+package executor
2+
3+import (
4+ "context"
5+ "os"
6+ "path/filepath"
7+ "testing"
8+)
9+
10+func TestScriptExecutor_NewScriptExecutor(t *testing.T) {
11+ executor := NewScriptExecutor()
12+ if executor == nil {
13+ t.Fatal("expected non-nil executor")
14+ }
15+}
16+
17+func TestScriptExecutor_ExecuteScript_Success(t *testing.T) {
18+ executor := NewScriptExecutor()
19+ tmpDir := t.TempDir()
20+
21+ // Create a simple test script
22+ scriptPath := filepath.Join(tmpDir, "test.sh")
23+ scriptContent := "#!/bin/bash\nexit 0"
24+ if err := os.WriteFile(scriptPath, []byte(scriptContent), 0755); err != nil {
25+ t.Fatalf("failed to create test script: %v", err)
26+ }
27+
28+ ctx := context.Background()
29+ err := executor.ExecuteScript(ctx, scriptPath, nil)
30+ if err != nil {
31+ t.Fatalf("expected no error, got %v", err)
32+ }
33+}
34+
35+func TestScriptExecutor_ExecuteScript_NotFound(t *testing.T) {
36+ executor := NewScriptExecutor()
37+ ctx := context.Background()
38+
39+ err := executor.ExecuteScript(ctx, "/nonexistent/script.sh", nil)
40+ if err == nil {
41+ t.Fatal("expected error for nonexistent script")
42+ }
43+}
44+
45+func TestScriptExecutor_ExecuteScript_Failure(t *testing.T) {
46+ executor := NewScriptExecutor()
47+ tmpDir := t.TempDir()
48+
49+ // Create a script that fails
50+ scriptPath := filepath.Join(tmpDir, "fail.sh")
51+ scriptContent := "#!/bin/bash\nexit 1"
52+ if err := os.WriteFile(scriptPath, []byte(scriptContent), 0755); err != nil {
53+ t.Fatalf("failed to create test script: %v", err)
54+ }
55+
56+ ctx := context.Background()
57+ err := executor.ExecuteScript(ctx, scriptPath, nil)
58+ if err == nil {
59+ t.Fatal("expected error for failed script")
60+ }
61+}
62+
63+func TestScriptExecutor_ExecuteScript_WithEnv(t *testing.T) {
64+ executor := NewScriptExecutor()
65+ tmpDir := t.TempDir()
66+
67+ // Create a script that uses environment variables
68+ scriptPath := filepath.Join(tmpDir, "env.sh")
69+ scriptContent := "#!/bin/bash\ntest \"$TEST_VAR\" = \"test_value\" || exit 1"
70+ if err := os.WriteFile(scriptPath, []byte(scriptContent), 0755); err != nil {
71+ t.Fatalf("failed to create test script: %v", err)
72+ }
73+
74+ ctx := context.Background()
75+ env := map[string]string{
76+ "TEST_VAR": "test_value",
77+ }
78+
79+ err := executor.ExecuteScript(ctx, scriptPath, env)
80+ if err != nil {
81+ t.Fatalf("expected no error, got %v", err)
82+ }
83+}
84+
85+func TestScriptExecutor_ExecuteScript_WithMultipleEnv(t *testing.T) {
86+ executor := NewScriptExecutor()
87+ tmpDir := t.TempDir()
88+
89+ // Create a script that checks multiple environment variables
90+ scriptPath := filepath.Join(tmpDir, "multienv.sh")
91+ scriptContent := "#!/bin/bash\ntest \"$VAR1\" = \"value1\" && test \"$VAR2\" = \"value2\" || exit 1"
92+ if err := os.WriteFile(scriptPath, []byte(scriptContent), 0755); err != nil {
93+ t.Fatalf("failed to create test script: %v", err)
94+ }
95+
96+ ctx := context.Background()
97+ env := map[string]string{
98+ "VAR1": "value1",
99+ "VAR2": "value2",
100+ }
101+
102+ err := executor.ExecuteScript(ctx, scriptPath, env)
103+ if err != nil {
104+ t.Fatalf("expected no error, got %v", err)
105+ }
106+}
107+
108+func TestScriptExecutor_ExecuteScript_Stderr(t *testing.T) {
109+ executor := NewScriptExecutor()
110+ tmpDir := t.TempDir()
111+
112+ // Create a script that writes to stderr
113+ scriptPath := filepath.Join(tmpDir, "stderr.sh")
114+ scriptContent := "#!/bin/bash\necho error >&2\nexit 1"
115+ if err := os.WriteFile(scriptPath, []byte(scriptContent), 0755); err != nil {
116+ t.Fatalf("failed to create test script: %v", err)
117+ }
118+
119+ ctx := context.Background()
120+ err := executor.ExecuteScript(ctx, scriptPath, nil)
121+ if err == nil {
122+ t.Fatal("expected error")
123+ }
124+
125+ // Verify stderr is captured in error message
126+ if err != nil && err.Error() == "" {
127+ t.Error("expected error message to contain stderr")
128+ }
129+}
130+
131+func TestScriptExecutor_ExecuteScript_ContextCancellation(t *testing.T) {
132+ executor := NewScriptExecutor()
133+ tmpDir := t.TempDir()
134+
135+ // Create a script that sleeps
136+ scriptPath := filepath.Join(tmpDir, "sleep.sh")
137+ scriptContent := "#!/bin/bash\nsleep 10"
138+ if err := os.WriteFile(scriptPath, []byte(scriptContent), 0755); err != nil {
139+ t.Fatalf("failed to create test script: %v", err)
140+ }
141+
142+ ctx, cancel := context.WithCancel(context.Background())
143+ cancel() // Cancel immediately
144+
145+ err := executor.ExecuteScript(ctx, scriptPath, nil)
146+ if err == nil {
147+ t.Fatal("expected error due to context cancellation")
148+ }
149+}
@@ -0,0 +1,149 @@
1+package executor
2+
3+import (
4+ "context"
5+ "os"
6+ "path/filepath"
7+ "testing"
8+)
9+
10+func TestScriptExecutor_NewScriptExecutor(t *testing.T) {
11+ executor := NewScriptExecutor()
12+ if executor == nil {
13+ t.Fatal("expected non-nil executor")
14+ }
15+}
16+
17+func TestScriptExecutor_ExecuteScript_Success(t *testing.T) {
18+ executor := NewScriptExecutor()
19+ tmpDir := t.TempDir()
20+
21+ // Create a simple test script
22+ scriptPath := filepath.Join(tmpDir, "test.sh")
23+ scriptContent := "#!/bin/bash\nexit 0"
24+ if err := os.WriteFile(scriptPath, []byte(scriptContent), 0755); err != nil {
25+ t.Fatalf("failed to create test script: %v", err)
26+ }
27+
28+ ctx := context.Background()
29+ err := executor.ExecuteScript(ctx, scriptPath, nil)
30+ if err != nil {
31+ t.Fatalf("expected no error, got %v", err)
32+ }
33+}
34+
35+func TestScriptExecutor_ExecuteScript_NotFound(t *testing.T) {
36+ executor := NewScriptExecutor()
37+ ctx := context.Background()
38+
39+ err := executor.ExecuteScript(ctx, "/nonexistent/script.sh", nil)
40+ if err == nil {
41+ t.Fatal("expected error for nonexistent script")
42+ }
43+}
44+
45+func TestScriptExecutor_ExecuteScript_Failure(t *testing.T) {
46+ executor := NewScriptExecutor()
47+ tmpDir := t.TempDir()
48+
49+ // Create a script that fails
50+ scriptPath := filepath.Join(tmpDir, "fail.sh")
51+ scriptContent := "#!/bin/bash\nexit 1"
52+ if err := os.WriteFile(scriptPath, []byte(scriptContent), 0755); err != nil {
53+ t.Fatalf("failed to create test script: %v", err)
54+ }
55+
56+ ctx := context.Background()
57+ err := executor.ExecuteScript(ctx, scriptPath, nil)
58+ if err == nil {
59+ t.Fatal("expected error for failed script")
60+ }
61+}
62+
63+func TestScriptExecutor_ExecuteScript_WithEnv(t *testing.T) {
64+ executor := NewScriptExecutor()
65+ tmpDir := t.TempDir()
66+
67+ // Create a script that uses environment variables
68+ scriptPath := filepath.Join(tmpDir, "env.sh")
69+ scriptContent := "#!/bin/bash\ntest \"$TEST_VAR\" = \"test_value\" || exit 1"
70+ if err := os.WriteFile(scriptPath, []byte(scriptContent), 0755); err != nil {
71+ t.Fatalf("failed to create test script: %v", err)
72+ }
73+
74+ ctx := context.Background()
75+ env := map[string]string{
76+ "TEST_VAR": "test_value",
77+ }
78+
79+ err := executor.ExecuteScript(ctx, scriptPath, env)
80+ if err != nil {
81+ t.Fatalf("expected no error, got %v", err)
82+ }
83+}
84+
85+func TestScriptExecutor_ExecuteScript_WithMultipleEnv(t *testing.T) {
86+ executor := NewScriptExecutor()
87+ tmpDir := t.TempDir()
88+
89+ // Create a script that checks multiple environment variables
90+ scriptPath := filepath.Join(tmpDir, "multienv.sh")
91+ scriptContent := "#!/bin/bash\ntest \"$VAR1\" = \"value1\" && test \"$VAR2\" = \"value2\" || exit 1"
92+ if err := os.WriteFile(scriptPath, []byte(scriptContent), 0755); err != nil {
93+ t.Fatalf("failed to create test script: %v", err)
94+ }
95+
96+ ctx := context.Background()
97+ env := map[string]string{
98+ "VAR1": "value1",
99+ "VAR2": "value2",
100+ }
101+
102+ err := executor.ExecuteScript(ctx, scriptPath, env)
103+ if err != nil {
104+ t.Fatalf("expected no error, got %v", err)
105+ }
106+}
107+
108+func TestScriptExecutor_ExecuteScript_Stderr(t *testing.T) {
109+ executor := NewScriptExecutor()
110+ tmpDir := t.TempDir()
111+
112+ // Create a script that writes to stderr
113+ scriptPath := filepath.Join(tmpDir, "stderr.sh")
114+ scriptContent := "#!/bin/bash\necho error >&2\nexit 1"
115+ if err := os.WriteFile(scriptPath, []byte(scriptContent), 0755); err != nil {
116+ t.Fatalf("failed to create test script: %v", err)
117+ }
118+
119+ ctx := context.Background()
120+ err := executor.ExecuteScript(ctx, scriptPath, nil)
121+ if err == nil {
122+ t.Fatal("expected error")
123+ }
124+
125+ // Verify stderr is captured in error message
126+ if err != nil && err.Error() == "" {
127+ t.Error("expected error message to contain stderr")
128+ }
129+}
130+
131+func TestScriptExecutor_ExecuteScript_ContextCancellation(t *testing.T) {
132+ executor := NewScriptExecutor()
133+ tmpDir := t.TempDir()
134+
135+ // Create a script that sleeps
136+ scriptPath := filepath.Join(tmpDir, "sleep.sh")
137+ scriptContent := "#!/bin/bash\nsleep 10"
138+ if err := os.WriteFile(scriptPath, []byte(scriptContent), 0755); err != nil {
139+ t.Fatalf("failed to create test script: %v", err)
140+ }
141+
142+ ctx, cancel := context.WithCancel(context.Background())
143+ cancel() // Cancel immediately
144+
145+ err := executor.ExecuteScript(ctx, scriptPath, nil)
146+ if err == nil {
147+ t.Fatal("expected error due to context cancellation")
148+ }
149+}
A internal/infrastructure/executor/shell.go +52 -0
@@ -0,0 +1,52 @@
1+package executor
2+
3+import (
4+ "bytes"
5+ "context"
6+ "fmt"
7+ "os"
8+ "os/exec"
9+)
10+
11+// ShellExecutor implements the CommandExecutor port using system shell commands
12+type ShellExecutor struct{}
13+
14+// NewShellExecutor creates a new shell executor
15+func NewShellExecutor() *ShellExecutor {
16+ return &ShellExecutor{}
17+}
18+
19+// Execute runs a command and returns output
20+func (se *ShellExecutor) Execute(ctx context.Context, command string, args ...string) ([]byte, error) {
21+ cmd := exec.CommandContext(ctx, command, args...)
22+ var stdout, stderr bytes.Buffer
23+ cmd.Stdout = &stdout
24+ cmd.Stderr = &stderr
25+
26+ if err := cmd.Run(); err != nil {
27+ return nil, fmt.Errorf("command failed: %w (stderr: %s)", err, stderr.String())
28+ }
29+
30+ return stdout.Bytes(), nil
31+}
32+
33+// ExecuteWithEnv runs a command with custom environment variables
34+func (se *ShellExecutor) ExecuteWithEnv(ctx context.Context, env map[string]string, command string, args ...string) ([]byte, error) {
35+ cmd := exec.CommandContext(ctx, command, args...)
36+ var stdout, stderr bytes.Buffer
37+ cmd.Stdout = &stdout
38+ cmd.Stderr = &stderr
39+
40+ // Build environment: start with current environment, then add/override with provided env
41+ cmdEnv := os.Environ()
42+ for key, value := range env {
43+ cmdEnv = append(cmdEnv, fmt.Sprintf("%s=%s", key, value))
44+ }
45+ cmd.Env = cmdEnv
46+
47+ if err := cmd.Run(); err != nil {
48+ return nil, fmt.Errorf("command failed: %w (stderr: %s)", err, stderr.String())
49+ }
50+
51+ return stdout.Bytes(), nil
52+}
@@ -0,0 +1,52 @@
1+package executor
2+
3+import (
4+ "bytes"
5+ "context"
6+ "fmt"
7+ "os"
8+ "os/exec"
9+)
10+
11+// ShellExecutor implements the CommandExecutor port using system shell commands
12+type ShellExecutor struct{}
13+
14+// NewShellExecutor creates a new shell executor
15+func NewShellExecutor() *ShellExecutor {
16+ return &ShellExecutor{}
17+}
18+
19+// Execute runs a command and returns output
20+func (se *ShellExecutor) Execute(ctx context.Context, command string, args ...string) ([]byte, error) {
21+ cmd := exec.CommandContext(ctx, command, args...)
22+ var stdout, stderr bytes.Buffer
23+ cmd.Stdout = &stdout
24+ cmd.Stderr = &stderr
25+
26+ if err := cmd.Run(); err != nil {
27+ return nil, fmt.Errorf("command failed: %w (stderr: %s)", err, stderr.String())
28+ }
29+
30+ return stdout.Bytes(), nil
31+}
32+
33+// ExecuteWithEnv runs a command with custom environment variables
34+func (se *ShellExecutor) ExecuteWithEnv(ctx context.Context, env map[string]string, command string, args ...string) ([]byte, error) {
35+ cmd := exec.CommandContext(ctx, command, args...)
36+ var stdout, stderr bytes.Buffer
37+ cmd.Stdout = &stdout
38+ cmd.Stderr = &stderr
39+
40+ // Build environment: start with current environment, then add/override with provided env
41+ cmdEnv := os.Environ()
42+ for key, value := range env {
43+ cmdEnv = append(cmdEnv, fmt.Sprintf("%s=%s", key, value))
44+ }
45+ cmd.Env = cmdEnv
46+
47+ if err := cmd.Run(); err != nil {
48+ return nil, fmt.Errorf("command failed: %w (stderr: %s)", err, stderr.String())
49+ }
50+
51+ return stdout.Bytes(), nil
52+}
A internal/infrastructure/executor/shell_test.go +141 -0
@@ -0,0 +1,141 @@
1+package executor
2+
3+import (
4+ "context"
5+ "strings"
6+ "testing"
7+ "time"
8+)
9+
10+func TestShellExecutor_Execute_Success(t *testing.T) {
11+ executor := NewShellExecutor()
12+ ctx := context.Background()
13+
14+ output, err := executor.Execute(ctx, "echo", "hello world")
15+ if err != nil {
16+ t.Fatalf("expected no error, got %v", err)
17+ }
18+
19+ expected := "hello world\n"
20+ if string(output) != expected {
21+ t.Errorf("expected %q, got %q", expected, string(output))
22+ }
23+}
24+
25+func TestShellExecutor_Execute_WithArgs(t *testing.T) {
26+ executor := NewShellExecutor()
27+ ctx := context.Background()
28+
29+ // Test with multiple arguments
30+ output, err := executor.Execute(ctx, "echo", "arg1", "arg2", "arg3")
31+ if err != nil {
32+ t.Fatalf("expected no error, got %v", err)
33+ }
34+
35+ if !strings.Contains(string(output), "arg1") {
36+ t.Errorf("expected arg1 in output, got %s", string(output))
37+ }
38+}
39+
40+func TestShellExecutor_Execute_CommandNotFound(t *testing.T) {
41+ executor := NewShellExecutor()
42+ ctx := context.Background()
43+
44+ _, err := executor.Execute(ctx, "nonexistentcommand12345", "arg")
45+ if err == nil {
46+ t.Fatal("expected error for nonexistent command")
47+ }
48+}
49+
50+func TestShellExecutor_Execute_CommandFailure(t *testing.T) {
51+ executor := NewShellExecutor()
52+ ctx := context.Background()
53+
54+ // false command always exits with code 1
55+ _, err := executor.Execute(ctx, "false")
56+ if err == nil {
57+ t.Fatal("expected error for failed command")
58+ }
59+}
60+
61+func TestShellExecutor_ExecuteWithEnv_Success(t *testing.T) {
62+ executor := NewShellExecutor()
63+ ctx := context.Background()
64+
65+ env := map[string]string{
66+ "TEST_VAR": "test_value",
67+ }
68+
69+ // Echo the environment variable
70+ output, err := executor.ExecuteWithEnv(ctx, env, "sh", "-c", "echo $TEST_VAR")
71+ if err != nil {
72+ t.Fatalf("expected no error, got %v", err)
73+ }
74+
75+ if !strings.Contains(string(output), "test_value") {
76+ t.Errorf("expected test_value in output, got %s", string(output))
77+ }
78+}
79+
80+func TestShellExecutor_ExecuteWithEnv_MultipleVars(t *testing.T) {
81+ executor := NewShellExecutor()
82+ ctx := context.Background()
83+
84+ env := map[string]string{
85+ "VAR1": "value1",
86+ "VAR2": "value2",
87+ }
88+
89+ output, err := executor.ExecuteWithEnv(ctx, env, "sh", "-c", "echo $VAR1 $VAR2")
90+ if err != nil {
91+ t.Fatalf("expected no error, got %v", err)
92+ }
93+
94+ output_str := string(output)
95+ if !strings.Contains(output_str, "value1") || !strings.Contains(output_str, "value2") {
96+ t.Errorf("expected both values in output, got %s", output_str)
97+ }
98+}
99+
100+func TestShellExecutor_Execute_ContextCancellation(t *testing.T) {
101+ executor := NewShellExecutor()
102+ ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond)
103+ defer cancel()
104+
105+ // sleep command will be interrupted
106+ _, err := executor.Execute(ctx, "sleep", "10")
107+ if err == nil {
108+ t.Fatal("expected error due to context cancellation")
109+ }
110+}
111+
112+func TestShellExecutor_Execute_EmptyOutput(t *testing.T) {
113+ executor := NewShellExecutor()
114+ ctx := context.Background()
115+
116+ // true command produces no output
117+ output, err := executor.Execute(ctx, "true")
118+ if err != nil {
119+ t.Fatalf("expected no error, got %v", err)
120+ }
121+
122+ if len(output) != 0 {
123+ t.Errorf("expected empty output, got %s", string(output))
124+ }
125+}
126+
127+func TestShellExecutor_Execute_StderrCapture(t *testing.T) {
128+ executor := NewShellExecutor()
129+ ctx := context.Background()
130+
131+ // sh command that writes to stderr
132+ _, err := executor.Execute(ctx, "sh", "-c", "echo error >&2; exit 1")
133+ if err == nil {
134+ t.Fatal("expected error")
135+ }
136+
137+ // Verify stderr is captured in error message
138+ if !strings.Contains(err.Error(), "error") {
139+ t.Errorf("expected 'error' in error message, got %s", err.Error())
140+ }
141+}
@@ -0,0 +1,141 @@
1+package executor
2+
3+import (
4+ "context"
5+ "strings"
6+ "testing"
7+ "time"
8+)
9+
10+func TestShellExecutor_Execute_Success(t *testing.T) {
11+ executor := NewShellExecutor()
12+ ctx := context.Background()
13+
14+ output, err := executor.Execute(ctx, "echo", "hello world")
15+ if err != nil {
16+ t.Fatalf("expected no error, got %v", err)
17+ }
18+
19+ expected := "hello world\n"
20+ if string(output) != expected {
21+ t.Errorf("expected %q, got %q", expected, string(output))
22+ }
23+}
24+
25+func TestShellExecutor_Execute_WithArgs(t *testing.T) {
26+ executor := NewShellExecutor()
27+ ctx := context.Background()
28+
29+ // Test with multiple arguments
30+ output, err := executor.Execute(ctx, "echo", "arg1", "arg2", "arg3")
31+ if err != nil {
32+ t.Fatalf("expected no error, got %v", err)
33+ }
34+
35+ if !strings.Contains(string(output), "arg1") {
36+ t.Errorf("expected arg1 in output, got %s", string(output))
37+ }
38+}
39+
40+func TestShellExecutor_Execute_CommandNotFound(t *testing.T) {
41+ executor := NewShellExecutor()
42+ ctx := context.Background()
43+
44+ _, err := executor.Execute(ctx, "nonexistentcommand12345", "arg")
45+ if err == nil {
46+ t.Fatal("expected error for nonexistent command")
47+ }
48+}
49+
50+func TestShellExecutor_Execute_CommandFailure(t *testing.T) {
51+ executor := NewShellExecutor()
52+ ctx := context.Background()
53+
54+ // false command always exits with code 1
55+ _, err := executor.Execute(ctx, "false")
56+ if err == nil {
57+ t.Fatal("expected error for failed command")
58+ }
59+}
60+
61+func TestShellExecutor_ExecuteWithEnv_Success(t *testing.T) {
62+ executor := NewShellExecutor()
63+ ctx := context.Background()
64+
65+ env := map[string]string{
66+ "TEST_VAR": "test_value",
67+ }
68+
69+ // Echo the environment variable
70+ output, err := executor.ExecuteWithEnv(ctx, env, "sh", "-c", "echo $TEST_VAR")
71+ if err != nil {
72+ t.Fatalf("expected no error, got %v", err)
73+ }
74+
75+ if !strings.Contains(string(output), "test_value") {
76+ t.Errorf("expected test_value in output, got %s", string(output))
77+ }
78+}
79+
80+func TestShellExecutor_ExecuteWithEnv_MultipleVars(t *testing.T) {
81+ executor := NewShellExecutor()
82+ ctx := context.Background()
83+
84+ env := map[string]string{
85+ "VAR1": "value1",
86+ "VAR2": "value2",
87+ }
88+
89+ output, err := executor.ExecuteWithEnv(ctx, env, "sh", "-c", "echo $VAR1 $VAR2")
90+ if err != nil {
91+ t.Fatalf("expected no error, got %v", err)
92+ }
93+
94+ output_str := string(output)
95+ if !strings.Contains(output_str, "value1") || !strings.Contains(output_str, "value2") {
96+ t.Errorf("expected both values in output, got %s", output_str)
97+ }
98+}
99+
100+func TestShellExecutor_Execute_ContextCancellation(t *testing.T) {
101+ executor := NewShellExecutor()
102+ ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond)
103+ defer cancel()
104+
105+ // sleep command will be interrupted
106+ _, err := executor.Execute(ctx, "sleep", "10")
107+ if err == nil {
108+ t.Fatal("expected error due to context cancellation")
109+ }
110+}
111+
112+func TestShellExecutor_Execute_EmptyOutput(t *testing.T) {
113+ executor := NewShellExecutor()
114+ ctx := context.Background()
115+
116+ // true command produces no output
117+ output, err := executor.Execute(ctx, "true")
118+ if err != nil {
119+ t.Fatalf("expected no error, got %v", err)
120+ }
121+
122+ if len(output) != 0 {
123+ t.Errorf("expected empty output, got %s", string(output))
124+ }
125+}
126+
127+func TestShellExecutor_Execute_StderrCapture(t *testing.T) {
128+ executor := NewShellExecutor()
129+ ctx := context.Background()
130+
131+ // sh command that writes to stderr
132+ _, err := executor.Execute(ctx, "sh", "-c", "echo error >&2; exit 1")
133+ if err == nil {
134+ t.Fatal("expected error")
135+ }
136+
137+ // Verify stderr is captured in error message
138+ if !strings.Contains(err.Error(), "error") {
139+ t.Errorf("expected 'error' in error message, got %s", err.Error())
140+ }
141+}
A internal/infrastructure/filesystem/local.go +92 -0
@@ -0,0 +1,92 @@
1+package filesystem
2+
3+import (
4+ "fmt"
5+ "os"
6+)
7+
8+// LocalFileSystem implements the FileSystem port using the OS filesystem
9+type LocalFileSystem struct{}
10+
11+// NewLocalFileSystem creates a new local filesystem adapter
12+func NewLocalFileSystem() *LocalFileSystem {
13+ return &LocalFileSystem{}
14+}
15+
16+// Stat returns file info for the given path
17+func (lfs *LocalFileSystem) Stat(name string) (os.FileInfo, error) {
18+ return os.Stat(name)
19+}
20+
21+// ReadFile reads entire file content
22+func (lfs *LocalFileSystem) ReadFile(name string) ([]byte, error) {
23+ return os.ReadFile(name)
24+}
25+
26+// WriteFile writes data to a file with the given permissions
27+func (lfs *LocalFileSystem) WriteFile(name string, data []byte, perm os.FileMode) error {
28+ return os.WriteFile(name, data, perm)
29+}
30+
31+// Create creates or truncates a file
32+func (lfs *LocalFileSystem) Create(name string) (LocalFile, error) {
33+ file, err := os.Create(name)
34+ if err != nil {
35+ return nil, err
36+ }
37+ return &localFileHandle{file: file}, nil
38+}
39+
40+// Chmod changes file permissions
41+func (lfs *LocalFileSystem) Chmod(name string, perm os.FileMode) error {
42+ return os.Chmod(name, perm)
43+}
44+
45+// MkdirAll creates directory path with parents
46+func (lfs *LocalFileSystem) MkdirAll(path string, perm os.FileMode) error {
47+ return os.MkdirAll(path, perm)
48+}
49+
50+// RemoveAll removes a file or directory recursively
51+func (lfs *LocalFileSystem) RemoveAll(path string) error {
52+ return os.RemoveAll(path)
53+}
54+
55+// Exists checks if a file/directory exists
56+func (lfs *LocalFileSystem) Exists(path string) (bool, error) {
57+ _, err := os.Stat(path)
58+ if err == nil {
59+ return true, nil
60+ }
61+ if os.IsNotExist(err) {
62+ return false, nil
63+ }
64+ return false, fmt.Errorf("failed to check file existence: %w", err)
65+}
66+
67+// LocalFile interface type for file operations
68+type LocalFile interface {
69+ Read(b []byte) (n int, err error)
70+ Write(b []byte) (n int, err error)
71+ Close() error
72+}
73+
74+// localFileHandle wraps os.File to implement the File port
75+type localFileHandle struct {
76+ file *os.File
77+}
78+
79+// Read reads up to len(b) bytes from the file
80+func (lfh *localFileHandle) Read(b []byte) (n int, err error) {
81+ return lfh.file.Read(b)
82+}
83+
84+// Write writes len(b) bytes to the file
85+func (lfh *localFileHandle) Write(b []byte) (n int, err error) {
86+ return lfh.file.Write(b)
87+}
88+
89+// Close closes the file
90+func (lfh *localFileHandle) Close() error {
91+ return lfh.file.Close()
92+}
@@ -0,0 +1,92 @@
1+package filesystem
2+
3+import (
4+ "fmt"
5+ "os"
6+)
7+
8+// LocalFileSystem implements the FileSystem port using the OS filesystem
9+type LocalFileSystem struct{}
10+
11+// NewLocalFileSystem creates a new local filesystem adapter
12+func NewLocalFileSystem() *LocalFileSystem {
13+ return &LocalFileSystem{}
14+}
15+
16+// Stat returns file info for the given path
17+func (lfs *LocalFileSystem) Stat(name string) (os.FileInfo, error) {
18+ return os.Stat(name)
19+}
20+
21+// ReadFile reads entire file content
22+func (lfs *LocalFileSystem) ReadFile(name string) ([]byte, error) {
23+ return os.ReadFile(name)
24+}
25+
26+// WriteFile writes data to a file with the given permissions
27+func (lfs *LocalFileSystem) WriteFile(name string, data []byte, perm os.FileMode) error {
28+ return os.WriteFile(name, data, perm)
29+}
30+
31+// Create creates or truncates a file
32+func (lfs *LocalFileSystem) Create(name string) (LocalFile, error) {
33+ file, err := os.Create(name)
34+ if err != nil {
35+ return nil, err
36+ }
37+ return &localFileHandle{file: file}, nil
38+}
39+
40+// Chmod changes file permissions
41+func (lfs *LocalFileSystem) Chmod(name string, perm os.FileMode) error {
42+ return os.Chmod(name, perm)
43+}
44+
45+// MkdirAll creates directory path with parents
46+func (lfs *LocalFileSystem) MkdirAll(path string, perm os.FileMode) error {
47+ return os.MkdirAll(path, perm)
48+}
49+
50+// RemoveAll removes a file or directory recursively
51+func (lfs *LocalFileSystem) RemoveAll(path string) error {
52+ return os.RemoveAll(path)
53+}
54+
55+// Exists checks if a file/directory exists
56+func (lfs *LocalFileSystem) Exists(path string) (bool, error) {
57+ _, err := os.Stat(path)
58+ if err == nil {
59+ return true, nil
60+ }
61+ if os.IsNotExist(err) {
62+ return false, nil
63+ }
64+ return false, fmt.Errorf("failed to check file existence: %w", err)
65+}
66+
67+// LocalFile interface type for file operations
68+type LocalFile interface {
69+ Read(b []byte) (n int, err error)
70+ Write(b []byte) (n int, err error)
71+ Close() error
72+}
73+
74+// localFileHandle wraps os.File to implement the File port
75+type localFileHandle struct {
76+ file *os.File
77+}
78+
79+// Read reads up to len(b) bytes from the file
80+func (lfh *localFileHandle) Read(b []byte) (n int, err error) {
81+ return lfh.file.Read(b)
82+}
83+
84+// Write writes len(b) bytes to the file
85+func (lfh *localFileHandle) Write(b []byte) (n int, err error) {
86+ return lfh.file.Write(b)
87+}
88+
89+// Close closes the file
90+func (lfh *localFileHandle) Close() error {
91+ return lfh.file.Close()
92+}
A internal/infrastructure/filesystem/local_test.go +273 -0
@@ -0,0 +1,273 @@
1+package filesystem
2+
3+import (
4+ "os"
5+ "path/filepath"
6+ "testing"
7+)
8+
9+func TestLocalFileSystem_Stat(t *testing.T) {
10+ fs := NewLocalFileSystem()
11+ tmpDir := t.TempDir()
12+
13+ // Create a test file
14+ testFile := filepath.Join(tmpDir, "test.txt")
15+ if err := os.WriteFile(testFile, []byte("test"), 0644); err != nil {
16+ t.Fatalf("failed to create test file: %v", err)
17+ }
18+
19+ info, err := fs.Stat(testFile)
20+ if err != nil {
21+ t.Fatalf("expected no error, got %v", err)
22+ }
23+
24+ if info.Name() != "test.txt" {
25+ t.Errorf("expected filename test.txt, got %s", info.Name())
26+ }
27+}
28+
29+func TestLocalFileSystem_StatNotFound(t *testing.T) {
30+ fs := NewLocalFileSystem()
31+
32+ _, err := fs.Stat("/nonexistent/path/file.txt")
33+ if err == nil {
34+ t.Fatal("expected error for nonexistent file")
35+ }
36+}
37+
38+func TestLocalFileSystem_ReadFile(t *testing.T) {
39+ fs := NewLocalFileSystem()
40+ tmpDir := t.TempDir()
41+
42+ testFile := filepath.Join(tmpDir, "test.txt")
43+ testContent := []byte("hello world")
44+ if err := os.WriteFile(testFile, testContent, 0644); err != nil {
45+ t.Fatalf("failed to create test file: %v", err)
46+ }
47+
48+ content, err := fs.ReadFile(testFile)
49+ if err != nil {
50+ t.Fatalf("expected no error, got %v", err)
51+ }
52+
53+ if string(content) != "hello world" {
54+ t.Errorf("expected 'hello world', got %s", string(content))
55+ }
56+}
57+
58+func TestLocalFileSystem_ReadFileNotFound(t *testing.T) {
59+ fs := NewLocalFileSystem()
60+
61+ _, err := fs.ReadFile("/nonexistent/file.txt")
62+ if err == nil {
63+ t.Fatal("expected error for nonexistent file")
64+ }
65+}
66+
67+func TestLocalFileSystem_WriteFile(t *testing.T) {
68+ fs := NewLocalFileSystem()
69+ tmpDir := t.TempDir()
70+
71+ testFile := filepath.Join(tmpDir, "test.txt")
72+ content := []byte("test content")
73+
74+ err := fs.WriteFile(testFile, content, 0644)
75+ if err != nil {
76+ t.Fatalf("expected no error, got %v", err)
77+ }
78+
79+ // Verify file was created
80+ data, err := os.ReadFile(testFile)
81+ if err != nil {
82+ t.Fatalf("failed to read file: %v", err)
83+ }
84+
85+ if string(data) != "test content" {
86+ t.Errorf("expected 'test content', got %s", string(data))
87+ }
88+}
89+
90+func TestLocalFileSystem_WriteFileOverwrite(t *testing.T) {
91+ fs := NewLocalFileSystem()
92+ tmpDir := t.TempDir()
93+
94+ testFile := filepath.Join(tmpDir, "test.txt")
95+
96+ // Write first time
97+ if err := fs.WriteFile(testFile, []byte("first"), 0644); err != nil {
98+ t.Fatalf("expected no error, got %v", err)
99+ }
100+
101+ // Overwrite
102+ if err := fs.WriteFile(testFile, []byte("second"), 0644); err != nil {
103+ t.Fatalf("expected no error, got %v", err)
104+ }
105+
106+ data, _ := os.ReadFile(testFile)
107+ if string(data) != "second" {
108+ t.Errorf("expected 'second', got %s", string(data))
109+ }
110+}
111+
112+func TestLocalFileSystem_Create(t *testing.T) {
113+ fs := NewLocalFileSystem()
114+ tmpDir := t.TempDir()
115+
116+ testFile := filepath.Join(tmpDir, "test.txt")
117+ file, err := fs.Create(testFile)
118+ if err != nil {
119+ t.Fatalf("expected no error, got %v", err)
120+ }
121+ defer file.Close()
122+
123+ n, err := file.Write([]byte("test content"))
124+ if err != nil {
125+ t.Fatalf("expected no error, got %v", err)
126+ }
127+
128+ if n != len("test content") {
129+ t.Errorf("expected to write 12 bytes, got %d", n)
130+ }
131+
132+ // Verify file exists
133+ if _, err := os.Stat(testFile); os.IsNotExist(err) {
134+ t.Error("expected file to exist")
135+ }
136+}
137+
138+func TestLocalFileSystem_CreateTruncates(t *testing.T) {
139+ fs := NewLocalFileSystem()
140+ tmpDir := t.TempDir()
141+
142+ testFile := filepath.Join(tmpDir, "test.txt")
143+
144+ // Create file with content
145+ if err := os.WriteFile(testFile, []byte("old content"), 0644); err != nil {
146+ t.Fatalf("failed to create test file: %v", err)
147+ }
148+
149+ // Create again should truncate
150+ file, err := fs.Create(testFile)
151+ if err != nil {
152+ t.Fatalf("expected no error, got %v", err)
153+ }
154+ file.Close()
155+
156+ // Verify file is empty
157+ info, _ := os.Stat(testFile)
158+ if info.Size() != 0 {
159+ t.Errorf("expected file size 0, got %d", info.Size())
160+ }
161+}
162+
163+func TestLocalFileSystem_Chmod(t *testing.T) {
164+ fs := NewLocalFileSystem()
165+ tmpDir := t.TempDir()
166+
167+ testFile := filepath.Join(tmpDir, "test.txt")
168+ if err := os.WriteFile(testFile, []byte("test"), 0644); err != nil {
169+ t.Fatalf("failed to create test file: %v", err)
170+ }
171+
172+ err := fs.Chmod(testFile, 0755)
173+ if err != nil {
174+ t.Fatalf("expected no error, got %v", err)
175+ }
176+
177+ info, _ := os.Stat(testFile)
178+ if info.Mode()&0755 != 0755 {
179+ t.Errorf("expected mode 0755, got %o", info.Mode()&0777)
180+ }
181+}
182+
183+func TestLocalFileSystem_MkdirAll(t *testing.T) {
184+ fs := NewLocalFileSystem()
185+ tmpDir := t.TempDir()
186+
187+ path := filepath.Join(tmpDir, "a", "b", "c")
188+ err := fs.MkdirAll(path, 0755)
189+ if err != nil {
190+ t.Fatalf("expected no error, got %v", err)
191+ }
192+
193+ info, err := os.Stat(path)
194+ if err != nil {
195+ t.Fatalf("expected directory to exist: %v", err)
196+ }
197+
198+ if !info.IsDir() {
199+ t.Error("expected directory")
200+ }
201+}
202+
203+func TestLocalFileSystem_RemoveAll(t *testing.T) {
204+ fs := NewLocalFileSystem()
205+ tmpDir := t.TempDir()
206+
207+ // Create directory with files
208+ testDir := filepath.Join(tmpDir, "test_dir")
209+ if err := os.MkdirAll(testDir, 0755); err != nil {
210+ t.Fatalf("failed to create test directory: %v", err)
211+ }
212+
213+ testFile := filepath.Join(testDir, "test.txt")
214+ if err := os.WriteFile(testFile, []byte("test"), 0644); err != nil {
215+ t.Fatalf("failed to create test file: %v", err)
216+ }
217+
218+ err := fs.RemoveAll(testDir)
219+ if err != nil {
220+ t.Fatalf("expected no error, got %v", err)
221+ }
222+
223+ // Verify directory was removed
224+ if _, err := os.Stat(testDir); !os.IsNotExist(err) {
225+ t.Error("expected directory to be removed")
226+ }
227+}
228+
229+func TestLocalFileSystem_Exists_FileExists(t *testing.T) {
230+ fs := NewLocalFileSystem()
231+ tmpDir := t.TempDir()
232+
233+ testFile := filepath.Join(tmpDir, "test.txt")
234+ if err := os.WriteFile(testFile, []byte("test"), 0644); err != nil {
235+ t.Fatalf("failed to create test file: %v", err)
236+ }
237+
238+ exists, err := fs.Exists(testFile)
239+ if err != nil {
240+ t.Fatalf("expected no error, got %v", err)
241+ }
242+
243+ if !exists {
244+ t.Error("expected file to exist")
245+ }
246+}
247+
248+func TestLocalFileSystem_Exists_FileNotExists(t *testing.T) {
249+ fs := NewLocalFileSystem()
250+
251+ exists, err := fs.Exists("/nonexistent/file.txt")
252+ if err != nil {
253+ t.Fatalf("expected no error, got %v", err)
254+ }
255+
256+ if exists {
257+ t.Error("expected file to not exist")
258+ }
259+}
260+
261+func TestLocalFileSystem_Exists_DirectoryExists(t *testing.T) {
262+ fs := NewLocalFileSystem()
263+ tmpDir := t.TempDir()
264+
265+ exists, err := fs.Exists(tmpDir)
266+ if err != nil {
267+ t.Fatalf("expected no error, got %v", err)
268+ }
269+
270+ if !exists {
271+ t.Error("expected directory to exist")
272+ }
273+}
@@ -0,0 +1,273 @@
1+package filesystem
2+
3+import (
4+ "os"
5+ "path/filepath"
6+ "testing"
7+)
8+
9+func TestLocalFileSystem_Stat(t *testing.T) {
10+ fs := NewLocalFileSystem()
11+ tmpDir := t.TempDir()
12+
13+ // Create a test file
14+ testFile := filepath.Join(tmpDir, "test.txt")
15+ if err := os.WriteFile(testFile, []byte("test"), 0644); err != nil {
16+ t.Fatalf("failed to create test file: %v", err)
17+ }
18+
19+ info, err := fs.Stat(testFile)
20+ if err != nil {
21+ t.Fatalf("expected no error, got %v", err)
22+ }
23+
24+ if info.Name() != "test.txt" {
25+ t.Errorf("expected filename test.txt, got %s", info.Name())
26+ }
27+}
28+
29+func TestLocalFileSystem_StatNotFound(t *testing.T) {
30+ fs := NewLocalFileSystem()
31+
32+ _, err := fs.Stat("/nonexistent/path/file.txt")
33+ if err == nil {
34+ t.Fatal("expected error for nonexistent file")
35+ }
36+}
37+
38+func TestLocalFileSystem_ReadFile(t *testing.T) {
39+ fs := NewLocalFileSystem()
40+ tmpDir := t.TempDir()
41+
42+ testFile := filepath.Join(tmpDir, "test.txt")
43+ testContent := []byte("hello world")
44+ if err := os.WriteFile(testFile, testContent, 0644); err != nil {
45+ t.Fatalf("failed to create test file: %v", err)
46+ }
47+
48+ content, err := fs.ReadFile(testFile)
49+ if err != nil {
50+ t.Fatalf("expected no error, got %v", err)
51+ }
52+
53+ if string(content) != "hello world" {
54+ t.Errorf("expected 'hello world', got %s", string(content))
55+ }
56+}
57+
58+func TestLocalFileSystem_ReadFileNotFound(t *testing.T) {
59+ fs := NewLocalFileSystem()
60+
61+ _, err := fs.ReadFile("/nonexistent/file.txt")
62+ if err == nil {
63+ t.Fatal("expected error for nonexistent file")
64+ }
65+}
66+
67+func TestLocalFileSystem_WriteFile(t *testing.T) {
68+ fs := NewLocalFileSystem()
69+ tmpDir := t.TempDir()
70+
71+ testFile := filepath.Join(tmpDir, "test.txt")
72+ content := []byte("test content")
73+
74+ err := fs.WriteFile(testFile, content, 0644)
75+ if err != nil {
76+ t.Fatalf("expected no error, got %v", err)
77+ }
78+
79+ // Verify file was created
80+ data, err := os.ReadFile(testFile)
81+ if err != nil {
82+ t.Fatalf("failed to read file: %v", err)
83+ }
84+
85+ if string(data) != "test content" {
86+ t.Errorf("expected 'test content', got %s", string(data))
87+ }
88+}
89+
90+func TestLocalFileSystem_WriteFileOverwrite(t *testing.T) {
91+ fs := NewLocalFileSystem()
92+ tmpDir := t.TempDir()
93+
94+ testFile := filepath.Join(tmpDir, "test.txt")
95+
96+ // Write first time
97+ if err := fs.WriteFile(testFile, []byte("first"), 0644); err != nil {
98+ t.Fatalf("expected no error, got %v", err)
99+ }
100+
101+ // Overwrite
102+ if err := fs.WriteFile(testFile, []byte("second"), 0644); err != nil {
103+ t.Fatalf("expected no error, got %v", err)
104+ }
105+
106+ data, _ := os.ReadFile(testFile)
107+ if string(data) != "second" {
108+ t.Errorf("expected 'second', got %s", string(data))
109+ }
110+}
111+
112+func TestLocalFileSystem_Create(t *testing.T) {
113+ fs := NewLocalFileSystem()
114+ tmpDir := t.TempDir()
115+
116+ testFile := filepath.Join(tmpDir, "test.txt")
117+ file, err := fs.Create(testFile)
118+ if err != nil {
119+ t.Fatalf("expected no error, got %v", err)
120+ }
121+ defer file.Close()
122+
123+ n, err := file.Write([]byte("test content"))
124+ if err != nil {
125+ t.Fatalf("expected no error, got %v", err)
126+ }
127+
128+ if n != len("test content") {
129+ t.Errorf("expected to write 12 bytes, got %d", n)
130+ }
131+
132+ // Verify file exists
133+ if _, err := os.Stat(testFile); os.IsNotExist(err) {
134+ t.Error("expected file to exist")
135+ }
136+}
137+
138+func TestLocalFileSystem_CreateTruncates(t *testing.T) {
139+ fs := NewLocalFileSystem()
140+ tmpDir := t.TempDir()
141+
142+ testFile := filepath.Join(tmpDir, "test.txt")
143+
144+ // Create file with content
145+ if err := os.WriteFile(testFile, []byte("old content"), 0644); err != nil {
146+ t.Fatalf("failed to create test file: %v", err)
147+ }
148+
149+ // Create again should truncate
150+ file, err := fs.Create(testFile)
151+ if err != nil {
152+ t.Fatalf("expected no error, got %v", err)
153+ }
154+ file.Close()
155+
156+ // Verify file is empty
157+ info, _ := os.Stat(testFile)
158+ if info.Size() != 0 {
159+ t.Errorf("expected file size 0, got %d", info.Size())
160+ }
161+}
162+
163+func TestLocalFileSystem_Chmod(t *testing.T) {
164+ fs := NewLocalFileSystem()
165+ tmpDir := t.TempDir()
166+
167+ testFile := filepath.Join(tmpDir, "test.txt")
168+ if err := os.WriteFile(testFile, []byte("test"), 0644); err != nil {
169+ t.Fatalf("failed to create test file: %v", err)
170+ }
171+
172+ err := fs.Chmod(testFile, 0755)
173+ if err != nil {
174+ t.Fatalf("expected no error, got %v", err)
175+ }
176+
177+ info, _ := os.Stat(testFile)
178+ if info.Mode()&0755 != 0755 {
179+ t.Errorf("expected mode 0755, got %o", info.Mode()&0777)
180+ }
181+}
182+
183+func TestLocalFileSystem_MkdirAll(t *testing.T) {
184+ fs := NewLocalFileSystem()
185+ tmpDir := t.TempDir()
186+
187+ path := filepath.Join(tmpDir, "a", "b", "c")
188+ err := fs.MkdirAll(path, 0755)
189+ if err != nil {
190+ t.Fatalf("expected no error, got %v", err)
191+ }
192+
193+ info, err := os.Stat(path)
194+ if err != nil {
195+ t.Fatalf("expected directory to exist: %v", err)
196+ }
197+
198+ if !info.IsDir() {
199+ t.Error("expected directory")
200+ }
201+}
202+
203+func TestLocalFileSystem_RemoveAll(t *testing.T) {
204+ fs := NewLocalFileSystem()
205+ tmpDir := t.TempDir()
206+
207+ // Create directory with files
208+ testDir := filepath.Join(tmpDir, "test_dir")
209+ if err := os.MkdirAll(testDir, 0755); err != nil {
210+ t.Fatalf("failed to create test directory: %v", err)
211+ }
212+
213+ testFile := filepath.Join(testDir, "test.txt")
214+ if err := os.WriteFile(testFile, []byte("test"), 0644); err != nil {
215+ t.Fatalf("failed to create test file: %v", err)
216+ }
217+
218+ err := fs.RemoveAll(testDir)
219+ if err != nil {
220+ t.Fatalf("expected no error, got %v", err)
221+ }
222+
223+ // Verify directory was removed
224+ if _, err := os.Stat(testDir); !os.IsNotExist(err) {
225+ t.Error("expected directory to be removed")
226+ }
227+}
228+
229+func TestLocalFileSystem_Exists_FileExists(t *testing.T) {
230+ fs := NewLocalFileSystem()
231+ tmpDir := t.TempDir()
232+
233+ testFile := filepath.Join(tmpDir, "test.txt")
234+ if err := os.WriteFile(testFile, []byte("test"), 0644); err != nil {
235+ t.Fatalf("failed to create test file: %v", err)
236+ }
237+
238+ exists, err := fs.Exists(testFile)
239+ if err != nil {
240+ t.Fatalf("expected no error, got %v", err)
241+ }
242+
243+ if !exists {
244+ t.Error("expected file to exist")
245+ }
246+}
247+
248+func TestLocalFileSystem_Exists_FileNotExists(t *testing.T) {
249+ fs := NewLocalFileSystem()
250+
251+ exists, err := fs.Exists("/nonexistent/file.txt")
252+ if err != nil {
253+ t.Fatalf("expected no error, got %v", err)
254+ }
255+
256+ if exists {
257+ t.Error("expected file to not exist")
258+ }
259+}
260+
261+func TestLocalFileSystem_Exists_DirectoryExists(t *testing.T) {
262+ fs := NewLocalFileSystem()
263+ tmpDir := t.TempDir()
264+
265+ exists, err := fs.Exists(tmpDir)
266+ if err != nil {
267+ t.Fatalf("expected no error, got %v", err)
268+ }
269+
270+ if !exists {
271+ t.Error("expected directory to exist")
272+ }
273+}
A internal/infrastructure/http/client.go +105 -0
@@ -0,0 +1,105 @@
1+package http
2+
3+import (
4+ "fmt"
5+ "io"
6+ nethttp "net/http"
7+ "time"
8+)
9+
10+// HTTPClient implements the HTTPClient port using the standard library
11+type HTTPClient struct {
12+ client *nethttp.Client
13+}
14+
15+// NewHTTPClient creates a new HTTP client with a default timeout
16+func NewHTTPClient() *HTTPClient {
17+ return &HTTPClient{
18+ client: &nethttp.Client{
19+ Timeout: 30 * time.Second,
20+ },
21+ }
22+}
23+
24+// NewHTTPClientWithTimeout creates a new HTTP client with a custom timeout
25+func NewHTTPClientWithTimeout(timeout time.Duration) *HTTPClient {
26+ return &HTTPClient{
27+ client: &nethttp.Client{
28+ Timeout: timeout,
29+ },
30+ }
31+}
32+
33+// Get performs an HTTP GET request
34+func (hc *HTTPClient) Get(url string) (HTTPResponse, error) {
35+ resp, err := hc.client.Get(url)
36+ if err != nil {
37+ return nil, fmt.Errorf("GET request failed: %w", err)
38+ }
39+
40+ // Read body into memory
41+ body, err := io.ReadAll(resp.Body)
42+ if err != nil {
43+ resp.Body.Close()
44+ return nil, fmt.Errorf("failed to read response body: %w", err)
45+ }
46+
47+ return &httpResponse{
48+ statusCode: resp.StatusCode,
49+ body: body,
50+ rawResp: resp,
51+ }, nil
52+}
53+
54+// Post performs an HTTP POST request
55+func (hc *HTTPClient) Post(url string, contentType string, body []byte) (HTTPResponse, error) {
56+ resp, err := hc.client.Post(url, contentType, nil)
57+ if err != nil {
58+ return nil, fmt.Errorf("POST request failed: %w", err)
59+ }
60+
61+ // Read body into memory
62+ respBody, err := io.ReadAll(resp.Body)
63+ if err != nil {
64+ resp.Body.Close()
65+ return nil, fmt.Errorf("failed to read response body: %w", err)
66+ }
67+
68+ return &httpResponse{
69+ statusCode: resp.StatusCode,
70+ body: respBody,
71+ rawResp: resp,
72+ }, nil
73+}
74+
75+// HTTPResponse interface for HTTP responses
76+type HTTPResponse interface {
77+ StatusCode() int
78+ Body() []byte
79+ Close() error
80+}
81+
82+// httpResponse implements the Response port
83+type httpResponse struct {
84+ statusCode int
85+ body []byte
86+ rawResp *nethttp.Response
87+}
88+
89+// StatusCode returns the HTTP status code
90+func (hr *httpResponse) StatusCode() int {
91+ return hr.statusCode
92+}
93+
94+// Body returns the response body
95+func (hr *httpResponse) Body() []byte {
96+ return hr.body
97+}
98+
99+// Close closes the response
100+func (hr *httpResponse) Close() error {
101+ if hr.rawResp != nil {
102+ return hr.rawResp.Body.Close()
103+ }
104+ return nil
105+}
@@ -0,0 +1,105 @@
1+package http
2+
3+import (
4+ "fmt"
5+ "io"
6+ nethttp "net/http"
7+ "time"
8+)
9+
10+// HTTPClient implements the HTTPClient port using the standard library
11+type HTTPClient struct {
12+ client *nethttp.Client
13+}
14+
15+// NewHTTPClient creates a new HTTP client with a default timeout
16+func NewHTTPClient() *HTTPClient {
17+ return &HTTPClient{
18+ client: &nethttp.Client{
19+ Timeout: 30 * time.Second,
20+ },
21+ }
22+}
23+
24+// NewHTTPClientWithTimeout creates a new HTTP client with a custom timeout
25+func NewHTTPClientWithTimeout(timeout time.Duration) *HTTPClient {
26+ return &HTTPClient{
27+ client: &nethttp.Client{
28+ Timeout: timeout,
29+ },
30+ }
31+}
32+
33+// Get performs an HTTP GET request
34+func (hc *HTTPClient) Get(url string) (HTTPResponse, error) {
35+ resp, err := hc.client.Get(url)
36+ if err != nil {
37+ return nil, fmt.Errorf("GET request failed: %w", err)
38+ }
39+
40+ // Read body into memory
41+ body, err := io.ReadAll(resp.Body)
42+ if err != nil {
43+ resp.Body.Close()
44+ return nil, fmt.Errorf("failed to read response body: %w", err)
45+ }
46+
47+ return &httpResponse{
48+ statusCode: resp.StatusCode,
49+ body: body,
50+ rawResp: resp,
51+ }, nil
52+}
53+
54+// Post performs an HTTP POST request
55+func (hc *HTTPClient) Post(url string, contentType string, body []byte) (HTTPResponse, error) {
56+ resp, err := hc.client.Post(url, contentType, nil)
57+ if err != nil {
58+ return nil, fmt.Errorf("POST request failed: %w", err)
59+ }
60+
61+ // Read body into memory
62+ respBody, err := io.ReadAll(resp.Body)
63+ if err != nil {
64+ resp.Body.Close()
65+ return nil, fmt.Errorf("failed to read response body: %w", err)
66+ }
67+
68+ return &httpResponse{
69+ statusCode: resp.StatusCode,
70+ body: respBody,
71+ rawResp: resp,
72+ }, nil
73+}
74+
75+// HTTPResponse interface for HTTP responses
76+type HTTPResponse interface {
77+ StatusCode() int
78+ Body() []byte
79+ Close() error
80+}
81+
82+// httpResponse implements the Response port
83+type httpResponse struct {
84+ statusCode int
85+ body []byte
86+ rawResp *nethttp.Response
87+}
88+
89+// StatusCode returns the HTTP status code
90+func (hr *httpResponse) StatusCode() int {
91+ return hr.statusCode
92+}
93+
94+// Body returns the response body
95+func (hr *httpResponse) Body() []byte {
96+ return hr.body
97+}
98+
99+// Close closes the response
100+func (hr *httpResponse) Close() error {
101+ if hr.rawResp != nil {
102+ return hr.rawResp.Body.Close()
103+ }
104+ return nil
105+}
A internal/infrastructure/http/client_test.go +191 -0
@@ -0,0 +1,191 @@
1+package http
2+
3+import (
4+ "net/http"
5+ "net/http/httptest"
6+ "strings"
7+ "testing"
8+ "time"
9+)
10+
11+func TestHTTPClient_NewHTTPClient(t *testing.T) {
12+ client := NewHTTPClient()
13+ if client == nil {
14+ t.Fatal("expected non-nil client")
15+ }
16+
17+ if client.client == nil {
18+ t.Fatal("expected non-nil internal client")
19+ }
20+}
21+
22+func TestHTTPClient_NewHTTPClientWithTimeout(t *testing.T) {
23+ timeout := 5 * time.Second
24+ client := NewHTTPClientWithTimeout(timeout)
25+
26+ if client == nil {
27+ t.Fatal("expected non-nil client")
28+ }
29+
30+ if client.client.Timeout != timeout {
31+ t.Errorf("expected timeout %v, got %v", timeout, client.client.Timeout)
32+ }
33+}
34+
35+func TestHTTPClient_Get_Success(t *testing.T) {
36+ // Create a test server
37+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
38+ w.WriteHeader(200)
39+ w.Write([]byte("test response"))
40+ }))
41+ defer server.Close()
42+
43+ client := NewHTTPClient()
44+ resp, err := client.Get(server.URL)
45+ defer resp.Close()
46+
47+ if err != nil {
48+ t.Fatalf("expected no error, got %v", err)
49+ }
50+
51+ if resp.StatusCode() != 200 {
52+ t.Errorf("expected status 200, got %d", resp.StatusCode())
53+ }
54+
55+ if string(resp.Body()) != "test response" {
56+ t.Errorf("expected 'test response', got %s", string(resp.Body()))
57+ }
58+}
59+
60+func TestHTTPClient_Get_StatusCode(t *testing.T) {
61+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
62+ w.WriteHeader(404)
63+ }))
64+ defer server.Close()
65+
66+ client := NewHTTPClient()
67+ resp, err := client.Get(server.URL)
68+ defer resp.Close()
69+
70+ if err != nil {
71+ t.Fatalf("expected no error, got %v", err)
72+ }
73+
74+ if resp.StatusCode() != 404 {
75+ t.Errorf("expected status 404, got %d", resp.StatusCode())
76+ }
77+}
78+
79+func TestHTTPClient_Get_EmptyBody(t *testing.T) {
80+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
81+ w.WriteHeader(204)
82+ }))
83+ defer server.Close()
84+
85+ client := NewHTTPClient()
86+ resp, err := client.Get(server.URL)
87+ defer resp.Close()
88+
89+ if err != nil {
90+ t.Fatalf("expected no error, got %v", err)
91+ }
92+
93+ if len(resp.Body()) != 0 {
94+ t.Errorf("expected empty body, got %s", string(resp.Body()))
95+ }
96+}
97+
98+func TestHTTPClient_Get_LargeBody(t *testing.T) {
99+ largeBody := strings.Repeat("a", 10000)
100+
101+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
102+ w.WriteHeader(200)
103+ w.Write([]byte(largeBody))
104+ }))
105+ defer server.Close()
106+
107+ client := NewHTTPClient()
108+ resp, err := client.Get(server.URL)
109+ defer resp.Close()
110+
111+ if err != nil {
112+ t.Fatalf("expected no error, got %v", err)
113+ }
114+
115+ if string(resp.Body()) != largeBody {
116+ t.Error("expected body to match large response")
117+ }
118+}
119+
120+func TestHTTPClient_Get_InvalidURL(t *testing.T) {
121+ client := NewHTTPClient()
122+ _, err := client.Get("http://nonexistent.invalid.domain.example/")
123+
124+ if err == nil {
125+ t.Fatal("expected error for invalid URL")
126+ }
127+}
128+
129+func TestHTTPClient_Post_Success(t *testing.T) {
130+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
131+ w.WriteHeader(201)
132+ w.Write([]byte("created"))
133+ }))
134+ defer server.Close()
135+
136+ client := NewHTTPClient()
137+ resp, err := client.Post(server.URL, "application/json", []byte(`{"key":"value"}`))
138+ defer resp.Close()
139+
140+ if err != nil {
141+ t.Fatalf("expected no error, got %v", err)
142+ }
143+
144+ if resp.StatusCode() != 201 {
145+ t.Errorf("expected status 201, got %d", resp.StatusCode())
146+ }
147+
148+ if string(resp.Body()) != "created" {
149+ t.Errorf("expected 'created', got %s", string(resp.Body()))
150+ }
151+}
152+
153+func TestHTTPClient_Post_InvalidURL(t *testing.T) {
154+ client := NewHTTPClient()
155+ _, err := client.Post("http://nonexistent.invalid.domain.example/", "application/json", []byte("test"))
156+
157+ if err == nil {
158+ t.Fatal("expected error for invalid URL")
159+ }
160+}
161+
162+func TestHTTPClient_Response_Close(t *testing.T) {
163+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
164+ w.WriteHeader(200)
165+ w.Write([]byte("test"))
166+ }))
167+ defer server.Close()
168+
169+ client := NewHTTPClient()
170+ resp, _ := client.Get(server.URL)
171+
172+ err := resp.Close()
173+ if err != nil {
174+ t.Errorf("expected no error closing response, got %v", err)
175+ }
176+}
177+
178+func TestHTTPClient_Get_Timeout(t *testing.T) {
179+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
180+ time.Sleep(2 * time.Second)
181+ w.WriteHeader(200)
182+ }))
183+ defer server.Close()
184+
185+ client := NewHTTPClientWithTimeout(100 * time.Millisecond)
186+ _, err := client.Get(server.URL)
187+
188+ if err == nil {
189+ t.Fatal("expected error due to timeout")
190+ }
191+}
@@ -0,0 +1,191 @@
1+package http
2+
3+import (
4+ "net/http"
5+ "net/http/httptest"
6+ "strings"
7+ "testing"
8+ "time"
9+)
10+
11+func TestHTTPClient_NewHTTPClient(t *testing.T) {
12+ client := NewHTTPClient()
13+ if client == nil {
14+ t.Fatal("expected non-nil client")
15+ }
16+
17+ if client.client == nil {
18+ t.Fatal("expected non-nil internal client")
19+ }
20+}
21+
22+func TestHTTPClient_NewHTTPClientWithTimeout(t *testing.T) {
23+ timeout := 5 * time.Second
24+ client := NewHTTPClientWithTimeout(timeout)
25+
26+ if client == nil {
27+ t.Fatal("expected non-nil client")
28+ }
29+
30+ if client.client.Timeout != timeout {
31+ t.Errorf("expected timeout %v, got %v", timeout, client.client.Timeout)
32+ }
33+}
34+
35+func TestHTTPClient_Get_Success(t *testing.T) {
36+ // Create a test server
37+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
38+ w.WriteHeader(200)
39+ w.Write([]byte("test response"))
40+ }))
41+ defer server.Close()
42+
43+ client := NewHTTPClient()
44+ resp, err := client.Get(server.URL)
45+ defer resp.Close()
46+
47+ if err != nil {
48+ t.Fatalf("expected no error, got %v", err)
49+ }
50+
51+ if resp.StatusCode() != 200 {
52+ t.Errorf("expected status 200, got %d", resp.StatusCode())
53+ }
54+
55+ if string(resp.Body()) != "test response" {
56+ t.Errorf("expected 'test response', got %s", string(resp.Body()))
57+ }
58+}
59+
60+func TestHTTPClient_Get_StatusCode(t *testing.T) {
61+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
62+ w.WriteHeader(404)
63+ }))
64+ defer server.Close()
65+
66+ client := NewHTTPClient()
67+ resp, err := client.Get(server.URL)
68+ defer resp.Close()
69+
70+ if err != nil {
71+ t.Fatalf("expected no error, got %v", err)
72+ }
73+
74+ if resp.StatusCode() != 404 {
75+ t.Errorf("expected status 404, got %d", resp.StatusCode())
76+ }
77+}
78+
79+func TestHTTPClient_Get_EmptyBody(t *testing.T) {
80+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
81+ w.WriteHeader(204)
82+ }))
83+ defer server.Close()
84+
85+ client := NewHTTPClient()
86+ resp, err := client.Get(server.URL)
87+ defer resp.Close()
88+
89+ if err != nil {
90+ t.Fatalf("expected no error, got %v", err)
91+ }
92+
93+ if len(resp.Body()) != 0 {
94+ t.Errorf("expected empty body, got %s", string(resp.Body()))
95+ }
96+}
97+
98+func TestHTTPClient_Get_LargeBody(t *testing.T) {
99+ largeBody := strings.Repeat("a", 10000)
100+
101+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
102+ w.WriteHeader(200)
103+ w.Write([]byte(largeBody))
104+ }))
105+ defer server.Close()
106+
107+ client := NewHTTPClient()
108+ resp, err := client.Get(server.URL)
109+ defer resp.Close()
110+
111+ if err != nil {
112+ t.Fatalf("expected no error, got %v", err)
113+ }
114+
115+ if string(resp.Body()) != largeBody {
116+ t.Error("expected body to match large response")
117+ }
118+}
119+
120+func TestHTTPClient_Get_InvalidURL(t *testing.T) {
121+ client := NewHTTPClient()
122+ _, err := client.Get("http://nonexistent.invalid.domain.example/")
123+
124+ if err == nil {
125+ t.Fatal("expected error for invalid URL")
126+ }
127+}
128+
129+func TestHTTPClient_Post_Success(t *testing.T) {
130+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
131+ w.WriteHeader(201)
132+ w.Write([]byte("created"))
133+ }))
134+ defer server.Close()
135+
136+ client := NewHTTPClient()
137+ resp, err := client.Post(server.URL, "application/json", []byte(`{"key":"value"}`))
138+ defer resp.Close()
139+
140+ if err != nil {
141+ t.Fatalf("expected no error, got %v", err)
142+ }
143+
144+ if resp.StatusCode() != 201 {
145+ t.Errorf("expected status 201, got %d", resp.StatusCode())
146+ }
147+
148+ if string(resp.Body()) != "created" {
149+ t.Errorf("expected 'created', got %s", string(resp.Body()))
150+ }
151+}
152+
153+func TestHTTPClient_Post_InvalidURL(t *testing.T) {
154+ client := NewHTTPClient()
155+ _, err := client.Post("http://nonexistent.invalid.domain.example/", "application/json", []byte("test"))
156+
157+ if err == nil {
158+ t.Fatal("expected error for invalid URL")
159+ }
160+}
161+
162+func TestHTTPClient_Response_Close(t *testing.T) {
163+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
164+ w.WriteHeader(200)
165+ w.Write([]byte("test"))
166+ }))
167+ defer server.Close()
168+
169+ client := NewHTTPClient()
170+ resp, _ := client.Get(server.URL)
171+
172+ err := resp.Close()
173+ if err != nil {
174+ t.Errorf("expected no error closing response, got %v", err)
175+ }
176+}
177+
178+func TestHTTPClient_Get_Timeout(t *testing.T) {
179+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
180+ time.Sleep(2 * time.Second)
181+ w.WriteHeader(200)
182+ }))
183+ defer server.Close()
184+
185+ client := NewHTTPClientWithTimeout(100 * time.Millisecond)
186+ _, err := client.Get(server.URL)
187+
188+ if err == nil {
189+ t.Fatal("expected error due to timeout")
190+ }
191+}
A internal/infrastructure/logger/slog_adapter.go +75 -0
@@ -0,0 +1,75 @@
1+package logger
2+
3+import (
4+ "fmt"
5+ "log/slog"
6+ "os"
7+ "path/filepath"
8+)
9+
10+// SlogAdapter implements the Logger port using Go's standard log/slog
11+type SlogAdapter struct {
12+ logger *slog.Logger
13+ logPath string
14+}
15+
16+// NewSlogAdapter creates a new slog-based logger
17+// If logPath is empty, logs are written to stderr only
18+func NewSlogAdapter(logPath string) (*SlogAdapter, error) {
19+ var logger *slog.Logger
20+
21+ if logPath != "" {
22+ // Ensure parent directory exists
23+ if err := os.MkdirAll(filepath.Dir(logPath), 0755); err != nil {
24+ return nil, fmt.Errorf("failed to create log directory: %w", err)
25+ }
26+
27+ // Open log file for appending
28+ file, err := os.OpenFile(logPath, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0644)
29+ if err != nil {
30+ return nil, fmt.Errorf("failed to open log file: %w", err)
31+ }
32+
33+ // Create slog handler with both stderr and file output
34+ handler := slog.NewTextHandler(file, &slog.HandlerOptions{
35+ Level: slog.LevelDebug,
36+ })
37+ logger = slog.New(handler)
38+ } else {
39+ // Log to stderr only
40+ handler := slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{
41+ Level: slog.LevelDebug,
42+ })
43+ logger = slog.New(handler)
44+ }
45+
46+ return &SlogAdapter{
47+ logger: logger,
48+ logPath: logPath,
49+ }, nil
50+}
51+
52+// Info logs an informational message
53+func (sa *SlogAdapter) Info(msg string, keysAndValues ...interface{}) {
54+ sa.logger.Info(msg, keysAndValues...)
55+}
56+
57+// Warn logs a warning message
58+func (sa *SlogAdapter) Warn(msg string, keysAndValues ...interface{}) {
59+ sa.logger.Warn(msg, keysAndValues...)
60+}
61+
62+// Error logs an error message
63+func (sa *SlogAdapter) Error(msg string, keysAndValues ...interface{}) {
64+ sa.logger.Error(msg, keysAndValues...)
65+}
66+
67+// Debug logs a debug message
68+func (sa *SlogAdapter) Debug(msg string, keysAndValues ...interface{}) {
69+ sa.logger.Debug(msg, keysAndValues...)
70+}
71+
72+// LogPath returns the path to the log file
73+func (sa *SlogAdapter) LogPath() string {
74+ return sa.logPath
75+}
@@ -0,0 +1,75 @@
1+package logger
2+
3+import (
4+ "fmt"
5+ "log/slog"
6+ "os"
7+ "path/filepath"
8+)
9+
10+// SlogAdapter implements the Logger port using Go's standard log/slog
11+type SlogAdapter struct {
12+ logger *slog.Logger
13+ logPath string
14+}
15+
16+// NewSlogAdapter creates a new slog-based logger
17+// If logPath is empty, logs are written to stderr only
18+func NewSlogAdapter(logPath string) (*SlogAdapter, error) {
19+ var logger *slog.Logger
20+
21+ if logPath != "" {
22+ // Ensure parent directory exists
23+ if err := os.MkdirAll(filepath.Dir(logPath), 0755); err != nil {
24+ return nil, fmt.Errorf("failed to create log directory: %w", err)
25+ }
26+
27+ // Open log file for appending
28+ file, err := os.OpenFile(logPath, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0644)
29+ if err != nil {
30+ return nil, fmt.Errorf("failed to open log file: %w", err)
31+ }
32+
33+ // Create slog handler with both stderr and file output
34+ handler := slog.NewTextHandler(file, &slog.HandlerOptions{
35+ Level: slog.LevelDebug,
36+ })
37+ logger = slog.New(handler)
38+ } else {
39+ // Log to stderr only
40+ handler := slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{
41+ Level: slog.LevelDebug,
42+ })
43+ logger = slog.New(handler)
44+ }
45+
46+ return &SlogAdapter{
47+ logger: logger,
48+ logPath: logPath,
49+ }, nil
50+}
51+
52+// Info logs an informational message
53+func (sa *SlogAdapter) Info(msg string, keysAndValues ...interface{}) {
54+ sa.logger.Info(msg, keysAndValues...)
55+}
56+
57+// Warn logs a warning message
58+func (sa *SlogAdapter) Warn(msg string, keysAndValues ...interface{}) {
59+ sa.logger.Warn(msg, keysAndValues...)
60+}
61+
62+// Error logs an error message
63+func (sa *SlogAdapter) Error(msg string, keysAndValues ...interface{}) {
64+ sa.logger.Error(msg, keysAndValues...)
65+}
66+
67+// Debug logs a debug message
68+func (sa *SlogAdapter) Debug(msg string, keysAndValues ...interface{}) {
69+ sa.logger.Debug(msg, keysAndValues...)
70+}
71+
72+// LogPath returns the path to the log file
73+func (sa *SlogAdapter) LogPath() string {
74+ return sa.logPath
75+}
A internal/infrastructure/logger/slog_adapter_test.go +178 -0
@@ -0,0 +1,178 @@
1+package logger
2+
3+import (
4+ "os"
5+ "path/filepath"
6+ "testing"
7+)
8+
9+func TestSlogAdapter_NewSlogAdapterWithoutFile(t *testing.T) {
10+ logger, err := NewSlogAdapter("")
11+ if err != nil {
12+ t.Fatalf("expected no error, got %v", err)
13+ }
14+
15+ if logger == nil {
16+ t.Fatal("expected non-nil logger")
17+ }
18+
19+ if logger.LogPath() != "" {
20+ t.Errorf("expected empty log path, got %s", logger.LogPath())
21+ }
22+}
23+
24+func TestSlogAdapter_NewSlogAdapterWithFile(t *testing.T) {
25+ tmpDir := t.TempDir()
26+ logPath := filepath.Join(tmpDir, "test.log")
27+
28+ logger, err := NewSlogAdapter(logPath)
29+ if err != nil {
30+ t.Fatalf("expected no error, got %v", err)
31+ }
32+
33+ if logger == nil {
34+ t.Fatal("expected non-nil logger")
35+ }
36+
37+ if logger.LogPath() != logPath {
38+ t.Errorf("expected log path %s, got %s", logPath, logger.LogPath())
39+ }
40+
41+ // Verify file was created
42+ if _, err := os.Stat(logPath); os.IsNotExist(err) {
43+ t.Error("expected log file to be created")
44+ }
45+}
46+
47+func TestSlogAdapter_NewSlogAdapterCreatesDirs(t *testing.T) {
48+ tmpDir := t.TempDir()
49+ logPath := filepath.Join(tmpDir, "subdir", "nested", "test.log")
50+
51+ logger, err := NewSlogAdapter(logPath)
52+ if err != nil {
53+ t.Fatalf("expected no error, got %v", err)
54+ }
55+
56+ if logger == nil {
57+ t.Fatal("expected non-nil logger")
58+ }
59+
60+ // Verify file was created with parent directories
61+ if _, err := os.Stat(logPath); os.IsNotExist(err) {
62+ t.Error("expected log file to be created with parent directories")
63+ }
64+}
65+
66+func TestSlogAdapter_Info(t *testing.T) {
67+ tmpDir := t.TempDir()
68+ logPath := filepath.Join(tmpDir, "test.log")
69+
70+ logger, err := NewSlogAdapter(logPath)
71+ if err != nil {
72+ t.Fatalf("expected no error, got %v", err)
73+ }
74+
75+ // Should not panic
76+ logger.Info("test message", "key", "value")
77+
78+ // Verify content was written
79+ content, err := os.ReadFile(logPath)
80+ if err != nil {
81+ t.Fatalf("failed to read log file: %v", err)
82+ }
83+
84+ if len(content) == 0 {
85+ t.Error("expected log content")
86+ }
87+}
88+
89+func TestSlogAdapter_Warn(t *testing.T) {
90+ tmpDir := t.TempDir()
91+ logPath := filepath.Join(tmpDir, "test.log")
92+
93+ logger, err := NewSlogAdapter(logPath)
94+ if err != nil {
95+ t.Fatalf("expected no error, got %v", err)
96+ }
97+
98+ // Should not panic
99+ logger.Warn("warning message", "warn_key", "warn_value")
100+
101+ content, err := os.ReadFile(logPath)
102+ if err != nil {
103+ t.Fatalf("failed to read log file: %v", err)
104+ }
105+
106+ if len(content) == 0 {
107+ t.Error("expected log content")
108+ }
109+}
110+
111+func TestSlogAdapter_Error(t *testing.T) {
112+ tmpDir := t.TempDir()
113+ logPath := filepath.Join(tmpDir, "test.log")
114+
115+ logger, err := NewSlogAdapter(logPath)
116+ if err != nil {
117+ t.Fatalf("expected no error, got %v", err)
118+ }
119+
120+ // Should not panic
121+ logger.Error("error message", "error_key", "error_value")
122+
123+ content, err := os.ReadFile(logPath)
124+ if err != nil {
125+ t.Fatalf("failed to read log file: %v", err)
126+ }
127+
128+ if len(content) == 0 {
129+ t.Error("expected log content")
130+ }
131+}
132+
133+func TestSlogAdapter_Debug(t *testing.T) {
134+ tmpDir := t.TempDir()
135+ logPath := filepath.Join(tmpDir, "test.log")
136+
137+ logger, err := NewSlogAdapter(logPath)
138+ if err != nil {
139+ t.Fatalf("expected no error, got %v", err)
140+ }
141+
142+ // Should not panic
143+ logger.Debug("debug message", "debug_key", "debug_value")
144+
145+ content, err := os.ReadFile(logPath)
146+ if err != nil {
147+ t.Fatalf("failed to read log file: %v", err)
148+ }
149+
150+ if len(content) == 0 {
151+ t.Error("expected log content")
152+ }
153+}
154+
155+func TestSlogAdapter_MultipleLogCalls(t *testing.T) {
156+ tmpDir := t.TempDir()
157+ logPath := filepath.Join(tmpDir, "test.log")
158+
159+ logger, err := NewSlogAdapter(logPath)
160+ if err != nil {
161+ t.Fatalf("expected no error, got %v", err)
162+ }
163+
164+ logger.Info("first message")
165+ logger.Warn("second message")
166+ logger.Error("third message")
167+ logger.Debug("fourth message")
168+
169+ content, err := os.ReadFile(logPath)
170+ if err != nil {
171+ t.Fatalf("failed to read log file: %v", err)
172+ }
173+
174+ // All messages should be present
175+ if len(content) == 0 {
176+ t.Error("expected log content")
177+ }
178+}
@@ -0,0 +1,178 @@
1+package logger
2+
3+import (
4+ "os"
5+ "path/filepath"
6+ "testing"
7+)
8+
9+func TestSlogAdapter_NewSlogAdapterWithoutFile(t *testing.T) {
10+ logger, err := NewSlogAdapter("")
11+ if err != nil {
12+ t.Fatalf("expected no error, got %v", err)
13+ }
14+
15+ if logger == nil {
16+ t.Fatal("expected non-nil logger")
17+ }
18+
19+ if logger.LogPath() != "" {
20+ t.Errorf("expected empty log path, got %s", logger.LogPath())
21+ }
22+}
23+
24+func TestSlogAdapter_NewSlogAdapterWithFile(t *testing.T) {
25+ tmpDir := t.TempDir()
26+ logPath := filepath.Join(tmpDir, "test.log")
27+
28+ logger, err := NewSlogAdapter(logPath)
29+ if err != nil {
30+ t.Fatalf("expected no error, got %v", err)
31+ }
32+
33+ if logger == nil {
34+ t.Fatal("expected non-nil logger")
35+ }
36+
37+ if logger.LogPath() != logPath {
38+ t.Errorf("expected log path %s, got %s", logPath, logger.LogPath())
39+ }
40+
41+ // Verify file was created
42+ if _, err := os.Stat(logPath); os.IsNotExist(err) {
43+ t.Error("expected log file to be created")
44+ }
45+}
46+
47+func TestSlogAdapter_NewSlogAdapterCreatesDirs(t *testing.T) {
48+ tmpDir := t.TempDir()
49+ logPath := filepath.Join(tmpDir, "subdir", "nested", "test.log")
50+
51+ logger, err := NewSlogAdapter(logPath)
52+ if err != nil {
53+ t.Fatalf("expected no error, got %v", err)
54+ }
55+
56+ if logger == nil {
57+ t.Fatal("expected non-nil logger")
58+ }
59+
60+ // Verify file was created with parent directories
61+ if _, err := os.Stat(logPath); os.IsNotExist(err) {
62+ t.Error("expected log file to be created with parent directories")
63+ }
64+}
65+
66+func TestSlogAdapter_Info(t *testing.T) {
67+ tmpDir := t.TempDir()
68+ logPath := filepath.Join(tmpDir, "test.log")
69+
70+ logger, err := NewSlogAdapter(logPath)
71+ if err != nil {
72+ t.Fatalf("expected no error, got %v", err)
73+ }
74+
75+ // Should not panic
76+ logger.Info("test message", "key", "value")
77+
78+ // Verify content was written
79+ content, err := os.ReadFile(logPath)
80+ if err != nil {
81+ t.Fatalf("failed to read log file: %v", err)
82+ }
83+
84+ if len(content) == 0 {
85+ t.Error("expected log content")
86+ }
87+}
88+
89+func TestSlogAdapter_Warn(t *testing.T) {
90+ tmpDir := t.TempDir()
91+ logPath := filepath.Join(tmpDir, "test.log")
92+
93+ logger, err := NewSlogAdapter(logPath)
94+ if err != nil {
95+ t.Fatalf("expected no error, got %v", err)
96+ }
97+
98+ // Should not panic
99+ logger.Warn("warning message", "warn_key", "warn_value")
100+
101+ content, err := os.ReadFile(logPath)
102+ if err != nil {
103+ t.Fatalf("failed to read log file: %v", err)
104+ }
105+
106+ if len(content) == 0 {
107+ t.Error("expected log content")
108+ }
109+}
110+
111+func TestSlogAdapter_Error(t *testing.T) {
112+ tmpDir := t.TempDir()
113+ logPath := filepath.Join(tmpDir, "test.log")
114+
115+ logger, err := NewSlogAdapter(logPath)
116+ if err != nil {
117+ t.Fatalf("expected no error, got %v", err)
118+ }
119+
120+ // Should not panic
121+ logger.Error("error message", "error_key", "error_value")
122+
123+ content, err := os.ReadFile(logPath)
124+ if err != nil {
125+ t.Fatalf("failed to read log file: %v", err)
126+ }
127+
128+ if len(content) == 0 {
129+ t.Error("expected log content")
130+ }
131+}
132+
133+func TestSlogAdapter_Debug(t *testing.T) {
134+ tmpDir := t.TempDir()
135+ logPath := filepath.Join(tmpDir, "test.log")
136+
137+ logger, err := NewSlogAdapter(logPath)
138+ if err != nil {
139+ t.Fatalf("expected no error, got %v", err)
140+ }
141+
142+ // Should not panic
143+ logger.Debug("debug message", "debug_key", "debug_value")
144+
145+ content, err := os.ReadFile(logPath)
146+ if err != nil {
147+ t.Fatalf("failed to read log file: %v", err)
148+ }
149+
150+ if len(content) == 0 {
151+ t.Error("expected log content")
152+ }
153+}
154+
155+func TestSlogAdapter_MultipleLogCalls(t *testing.T) {
156+ tmpDir := t.TempDir()
157+ logPath := filepath.Join(tmpDir, "test.log")
158+
159+ logger, err := NewSlogAdapter(logPath)
160+ if err != nil {
161+ t.Fatalf("expected no error, got %v", err)
162+ }
163+
164+ logger.Info("first message")
165+ logger.Warn("second message")
166+ logger.Error("third message")
167+ logger.Debug("fourth message")
168+
169+ content, err := os.ReadFile(logPath)
170+ if err != nil {
171+ t.Fatalf("failed to read log file: %v", err)
172+ }
173+
174+ // All messages should be present
175+ if len(content) == 0 {
176+ t.Error("expected log content")
177+ }
178+}
A internal/infrastructure/persistence/file_repository.go +85 -0
@@ -0,0 +1,85 @@
1+package persistence
2+
3+import (
4+ "context"
5+ "fmt"
6+ "os"
7+ "path/filepath"
8+)
9+
10+// FileRepository implements the InstallationRepository port using filesystem storage
11+type FileRepository struct {
12+ basePath string
13+}
14+
15+// NewFileRepository creates a new file-based repository
16+// basePath is the directory where installation state files will be stored
17+func NewFileRepository(basePath string) (*FileRepository, error) {
18+ // Ensure base path exists
19+ if err := os.MkdirAll(basePath, 0755); err != nil {
20+ return nil, fmt.Errorf("failed to create repository base path: %w", err)
21+ }
22+
23+ return &FileRepository{
24+ basePath: basePath,
25+ }, nil
26+}
27+
28+// Save persists the installation state to a file
29+func (fr *FileRepository) Save(ctx context.Context, installationID string, state string) error {
30+ select {
31+ case <-ctx.Done():
32+ return ctx.Err()
33+ default:
34+ }
35+
36+ filePath := filepath.Join(fr.basePath, installationID+".state")
37+
38+ // Write state to file
39+ if err := os.WriteFile(filePath, []byte(state), 0644); err != nil {
40+ return fmt.Errorf("failed to save installation state: %w", err)
41+ }
42+
43+ return nil
44+}
45+
46+// Load retrieves the installation state from a file
47+func (fr *FileRepository) Load(ctx context.Context, installationID string) (string, error) {
48+ select {
49+ case <-ctx.Done():
50+ return "", ctx.Err()
51+ default:
52+ }
53+
54+ filePath := filepath.Join(fr.basePath, installationID+".state")
55+
56+ data, err := os.ReadFile(filePath)
57+ if err != nil {
58+ if os.IsNotExist(err) {
59+ return "", fmt.Errorf("installation state not found: %w", err)
60+ }
61+ return "", fmt.Errorf("failed to load installation state: %w", err)
62+ }
63+
64+ return string(data), nil
65+}
66+
67+// Exists checks if an installation state exists
68+func (fr *FileRepository) Exists(ctx context.Context, installationID string) (bool, error) {
69+ select {
70+ case <-ctx.Done():
71+ return false, ctx.Err()
72+ default:
73+ }
74+
75+ filePath := filepath.Join(fr.basePath, installationID+".state")
76+
77+ _, err := os.Stat(filePath)
78+ if err == nil {
79+ return true, nil
80+ }
81+ if os.IsNotExist(err) {
82+ return false, nil
83+ }
84+ return false, fmt.Errorf("failed to check installation existence: %w", err)
85+}
@@ -0,0 +1,85 @@
1+package persistence
2+
3+import (
4+ "context"
5+ "fmt"
6+ "os"
7+ "path/filepath"
8+)
9+
10+// FileRepository implements the InstallationRepository port using filesystem storage
11+type FileRepository struct {
12+ basePath string
13+}
14+
15+// NewFileRepository creates a new file-based repository
16+// basePath is the directory where installation state files will be stored
17+func NewFileRepository(basePath string) (*FileRepository, error) {
18+ // Ensure base path exists
19+ if err := os.MkdirAll(basePath, 0755); err != nil {
20+ return nil, fmt.Errorf("failed to create repository base path: %w", err)
21+ }
22+
23+ return &FileRepository{
24+ basePath: basePath,
25+ }, nil
26+}
27+
28+// Save persists the installation state to a file
29+func (fr *FileRepository) Save(ctx context.Context, installationID string, state string) error {
30+ select {
31+ case <-ctx.Done():
32+ return ctx.Err()
33+ default:
34+ }
35+
36+ filePath := filepath.Join(fr.basePath, installationID+".state")
37+
38+ // Write state to file
39+ if err := os.WriteFile(filePath, []byte(state), 0644); err != nil {
40+ return fmt.Errorf("failed to save installation state: %w", err)
41+ }
42+
43+ return nil
44+}
45+
46+// Load retrieves the installation state from a file
47+func (fr *FileRepository) Load(ctx context.Context, installationID string) (string, error) {
48+ select {
49+ case <-ctx.Done():
50+ return "", ctx.Err()
51+ default:
52+ }
53+
54+ filePath := filepath.Join(fr.basePath, installationID+".state")
55+
56+ data, err := os.ReadFile(filePath)
57+ if err != nil {
58+ if os.IsNotExist(err) {
59+ return "", fmt.Errorf("installation state not found: %w", err)
60+ }
61+ return "", fmt.Errorf("failed to load installation state: %w", err)
62+ }
63+
64+ return string(data), nil
65+}
66+
67+// Exists checks if an installation state exists
68+func (fr *FileRepository) Exists(ctx context.Context, installationID string) (bool, error) {
69+ select {
70+ case <-ctx.Done():
71+ return false, ctx.Err()
72+ default:
73+ }
74+
75+ filePath := filepath.Join(fr.basePath, installationID+".state")
76+
77+ _, err := os.Stat(filePath)
78+ if err == nil {
79+ return true, nil
80+ }
81+ if os.IsNotExist(err) {
82+ return false, nil
83+ }
84+ return false, fmt.Errorf("failed to check installation existence: %w", err)
85+}
A internal/infrastructure/persistence/file_repository_test.go +263 -0
@@ -0,0 +1,263 @@
1+package persistence
2+
3+import (
4+ "context"
5+ "os"
6+ "path/filepath"
7+ "testing"
8+ "time"
9+)
10+
11+func TestFileRepository_NewFileRepository(t *testing.T) {
12+ tmpDir := t.TempDir()
13+
14+ repo, err := NewFileRepository(tmpDir)
15+ if err != nil {
16+ t.Fatalf("expected no error, got %v", err)
17+ }
18+
19+ if repo == nil {
20+ t.Fatal("expected non-nil repository")
21+ }
22+
23+ if repo.basePath != tmpDir {
24+ t.Errorf("expected basePath %s, got %s", tmpDir, repo.basePath)
25+ }
26+}
27+
28+func TestFileRepository_NewFileRepository_CreatesDirectory(t *testing.T) {
29+ tmpDir := t.TempDir()
30+ nestedPath := filepath.Join(tmpDir, "nested", "path")
31+
32+ repo, err := NewFileRepository(nestedPath)
33+ if err != nil {
34+ t.Fatalf("expected no error, got %v", err)
35+ }
36+
37+ if repo == nil {
38+ t.Fatal("expected non-nil repository")
39+ }
40+
41+ // Verify directory was created
42+ info, err := os.Stat(nestedPath)
43+ if err != nil {
44+ t.Fatalf("expected directory to exist: %v", err)
45+ }
46+
47+ if !info.IsDir() {
48+ t.Error("expected directory")
49+ }
50+}
51+
52+func TestFileRepository_Save_Success(t *testing.T) {
53+ tmpDir := t.TempDir()
54+ repo, _ := NewFileRepository(tmpDir)
55+
56+ ctx := context.Background()
57+ state := "test installation state"
58+
59+ err := repo.Save(ctx, "install-001", state)
60+ if err != nil {
61+ t.Fatalf("expected no error, got %v", err)
62+ }
63+
64+ // Verify file was created
65+ filePath := filepath.Join(tmpDir, "install-001.state")
66+ if _, err := os.Stat(filePath); os.IsNotExist(err) {
67+ t.Error("expected state file to be created")
68+ }
69+}
70+
71+func TestFileRepository_Save_Content(t *testing.T) {
72+ tmpDir := t.TempDir()
73+ repo, _ := NewFileRepository(tmpDir)
74+
75+ ctx := context.Background()
76+ state := "test installation state with content"
77+
78+ repo.Save(ctx, "install-001", state)
79+
80+ // Verify file content
81+ filePath := filepath.Join(tmpDir, "install-001.state")
82+ content, _ := os.ReadFile(filePath)
83+
84+ if string(content) != state {
85+ t.Errorf("expected %s, got %s", state, string(content))
86+ }
87+}
88+
89+func TestFileRepository_Save_Overwrite(t *testing.T) {
90+ tmpDir := t.TempDir()
91+ repo, _ := NewFileRepository(tmpDir)
92+
93+ ctx := context.Background()
94+
95+ // Save first state
96+ repo.Save(ctx, "install-001", "first state")
97+
98+ // Overwrite with second state
99+ secondState := "second state"
100+ repo.Save(ctx, "install-001", secondState)
101+
102+ // Verify file content is updated
103+ filePath := filepath.Join(tmpDir, "install-001.state")
104+ content, _ := os.ReadFile(filePath)
105+
106+ if string(content) != secondState {
107+ t.Errorf("expected %s, got %s", secondState, string(content))
108+ }
109+}
110+
111+func TestFileRepository_Load_Success(t *testing.T) {
112+ tmpDir := t.TempDir()
113+ repo, _ := NewFileRepository(tmpDir)
114+
115+ // Save first
116+ ctx := context.Background()
117+ state := "test installation state"
118+ repo.Save(ctx, "install-001", state)
119+
120+ // Load
121+ loaded, err := repo.Load(ctx, "install-001")
122+ if err != nil {
123+ t.Fatalf("expected no error, got %v", err)
124+ }
125+
126+ if loaded != state {
127+ t.Errorf("expected %s, got %s", state, loaded)
128+ }
129+}
130+
131+func TestFileRepository_Load_NotFound(t *testing.T) {
132+ tmpDir := t.TempDir()
133+ repo, _ := NewFileRepository(tmpDir)
134+
135+ ctx := context.Background()
136+ _, err := repo.Load(ctx, "nonexistent")
137+
138+ if err == nil {
139+ t.Fatal("expected error for nonexistent installation")
140+ }
141+}
142+
143+func TestFileRepository_Exists_True(t *testing.T) {
144+ tmpDir := t.TempDir()
145+ repo, _ := NewFileRepository(tmpDir)
146+
147+ // Save first
148+ ctx := context.Background()
149+ repo.Save(ctx, "install-001", "state")
150+
151+ // Check exists
152+ exists, err := repo.Exists(ctx, "install-001")
153+ if err != nil {
154+ t.Fatalf("expected no error, got %v", err)
155+ }
156+
157+ if !exists {
158+ t.Error("expected installation to exist")
159+ }
160+}
161+
162+func TestFileRepository_Exists_False(t *testing.T) {
163+ tmpDir := t.TempDir()
164+ repo, _ := NewFileRepository(tmpDir)
165+
166+ ctx := context.Background()
167+ exists, err := repo.Exists(ctx, "nonexistent")
168+
169+ if err != nil {
170+ t.Fatalf("expected no error, got %v", err)
171+ }
172+
173+ if exists {
174+ t.Error("expected installation to not exist")
175+ }
176+}
177+
178+func TestFileRepository_Save_ContextCancelled(t *testing.T) {
179+ tmpDir := t.TempDir()
180+ repo, _ := NewFileRepository(tmpDir)
181+
182+ ctx, cancel := context.WithCancel(context.Background())
183+ cancel()
184+
185+ err := repo.Save(ctx, "install-001", "state")
186+ if err == nil {
187+ t.Fatal("expected error due to context cancellation")
188+ }
189+}
190+
191+func TestFileRepository_Load_ContextCancelled(t *testing.T) {
192+ tmpDir := t.TempDir()
193+ repo, _ := NewFileRepository(tmpDir)
194+
195+ // Save first
196+ ctx := context.Background()
197+ repo.Save(ctx, "install-001", "state")
198+
199+ // Load with cancelled context
200+ ctx2, cancel := context.WithCancel(context.Background())
201+ cancel()
202+
203+ _, err := repo.Load(ctx2, "install-001")
204+ if err == nil {
205+ t.Fatal("expected error due to context cancellation")
206+ }
207+}
208+
209+func TestFileRepository_Exists_ContextCancelled(t *testing.T) {
210+ tmpDir := t.TempDir()
211+ repo, _ := NewFileRepository(tmpDir)
212+
213+ ctx, cancel := context.WithCancel(context.Background())
214+ cancel()
215+
216+ _, err := repo.Exists(ctx, "install-001")
217+ if err == nil {
218+ t.Fatal("expected error due to context cancellation")
219+ }
220+}
221+
222+func TestFileRepository_MultipleInstallations(t *testing.T) {
223+ tmpDir := t.TempDir()
224+ repo, _ := NewFileRepository(tmpDir)
225+
226+ ctx := context.Background()
227+
228+ // Save multiple installations
229+ repo.Save(ctx, "install-001", "state 1")
230+ repo.Save(ctx, "install-002", "state 2")
231+ repo.Save(ctx, "install-003", "state 3")
232+
233+ // Load each one
234+ state1, _ := repo.Load(ctx, "install-001")
235+ state2, _ := repo.Load(ctx, "install-002")
236+ state3, _ := repo.Load(ctx, "install-003")
237+
238+ if state1 != "state 1" {
239+ t.Errorf("expected 'state 1', got %s", state1)
240+ }
241+ if state2 != "state 2" {
242+ t.Errorf("expected 'state 2', got %s", state2)
243+ }
244+ if state3 != "state 3" {
245+ t.Errorf("expected 'state 3', got %s", state3)
246+ }
247+}
248+
249+func TestFileRepository_Save_ContextTimeout(t *testing.T) {
250+ tmpDir := t.TempDir()
251+ repo, _ := NewFileRepository(tmpDir)
252+
253+ ctx, cancel := context.WithTimeout(context.Background(), 1*time.Nanosecond)
254+ defer cancel()
255+
256+ // Give it time to timeout
257+ time.Sleep(10 * time.Millisecond)
258+
259+ err := repo.Save(ctx, "install-001", "state")
260+ if err == nil {
261+ t.Fatal("expected error due to context timeout")
262+ }
263+}
@@ -0,0 +1,263 @@
1+package persistence
2+
3+import (
4+ "context"
5+ "os"
6+ "path/filepath"
7+ "testing"
8+ "time"
9+)
10+
11+func TestFileRepository_NewFileRepository(t *testing.T) {
12+ tmpDir := t.TempDir()
13+
14+ repo, err := NewFileRepository(tmpDir)
15+ if err != nil {
16+ t.Fatalf("expected no error, got %v", err)
17+ }
18+
19+ if repo == nil {
20+ t.Fatal("expected non-nil repository")
21+ }
22+
23+ if repo.basePath != tmpDir {
24+ t.Errorf("expected basePath %s, got %s", tmpDir, repo.basePath)
25+ }
26+}
27+
28+func TestFileRepository_NewFileRepository_CreatesDirectory(t *testing.T) {
29+ tmpDir := t.TempDir()
30+ nestedPath := filepath.Join(tmpDir, "nested", "path")
31+
32+ repo, err := NewFileRepository(nestedPath)
33+ if err != nil {
34+ t.Fatalf("expected no error, got %v", err)
35+ }
36+
37+ if repo == nil {
38+ t.Fatal("expected non-nil repository")
39+ }
40+
41+ // Verify directory was created
42+ info, err := os.Stat(nestedPath)
43+ if err != nil {
44+ t.Fatalf("expected directory to exist: %v", err)
45+ }
46+
47+ if !info.IsDir() {
48+ t.Error("expected directory")
49+ }
50+}
51+
52+func TestFileRepository_Save_Success(t *testing.T) {
53+ tmpDir := t.TempDir()
54+ repo, _ := NewFileRepository(tmpDir)
55+
56+ ctx := context.Background()
57+ state := "test installation state"
58+
59+ err := repo.Save(ctx, "install-001", state)
60+ if err != nil {
61+ t.Fatalf("expected no error, got %v", err)
62+ }
63+
64+ // Verify file was created
65+ filePath := filepath.Join(tmpDir, "install-001.state")
66+ if _, err := os.Stat(filePath); os.IsNotExist(err) {
67+ t.Error("expected state file to be created")
68+ }
69+}
70+
71+func TestFileRepository_Save_Content(t *testing.T) {
72+ tmpDir := t.TempDir()
73+ repo, _ := NewFileRepository(tmpDir)
74+
75+ ctx := context.Background()
76+ state := "test installation state with content"
77+
78+ repo.Save(ctx, "install-001", state)
79+
80+ // Verify file content
81+ filePath := filepath.Join(tmpDir, "install-001.state")
82+ content, _ := os.ReadFile(filePath)
83+
84+ if string(content) != state {
85+ t.Errorf("expected %s, got %s", state, string(content))
86+ }
87+}
88+
89+func TestFileRepository_Save_Overwrite(t *testing.T) {
90+ tmpDir := t.TempDir()
91+ repo, _ := NewFileRepository(tmpDir)
92+
93+ ctx := context.Background()
94+
95+ // Save first state
96+ repo.Save(ctx, "install-001", "first state")
97+
98+ // Overwrite with second state
99+ secondState := "second state"
100+ repo.Save(ctx, "install-001", secondState)
101+
102+ // Verify file content is updated
103+ filePath := filepath.Join(tmpDir, "install-001.state")
104+ content, _ := os.ReadFile(filePath)
105+
106+ if string(content) != secondState {
107+ t.Errorf("expected %s, got %s", secondState, string(content))
108+ }
109+}
110+
111+func TestFileRepository_Load_Success(t *testing.T) {
112+ tmpDir := t.TempDir()
113+ repo, _ := NewFileRepository(tmpDir)
114+
115+ // Save first
116+ ctx := context.Background()
117+ state := "test installation state"
118+ repo.Save(ctx, "install-001", state)
119+
120+ // Load
121+ loaded, err := repo.Load(ctx, "install-001")
122+ if err != nil {
123+ t.Fatalf("expected no error, got %v", err)
124+ }
125+
126+ if loaded != state {
127+ t.Errorf("expected %s, got %s", state, loaded)
128+ }
129+}
130+
131+func TestFileRepository_Load_NotFound(t *testing.T) {
132+ tmpDir := t.TempDir()
133+ repo, _ := NewFileRepository(tmpDir)
134+
135+ ctx := context.Background()
136+ _, err := repo.Load(ctx, "nonexistent")
137+
138+ if err == nil {
139+ t.Fatal("expected error for nonexistent installation")
140+ }
141+}
142+
143+func TestFileRepository_Exists_True(t *testing.T) {
144+ tmpDir := t.TempDir()
145+ repo, _ := NewFileRepository(tmpDir)
146+
147+ // Save first
148+ ctx := context.Background()
149+ repo.Save(ctx, "install-001", "state")
150+
151+ // Check exists
152+ exists, err := repo.Exists(ctx, "install-001")
153+ if err != nil {
154+ t.Fatalf("expected no error, got %v", err)
155+ }
156+
157+ if !exists {
158+ t.Error("expected installation to exist")
159+ }
160+}
161+
162+func TestFileRepository_Exists_False(t *testing.T) {
163+ tmpDir := t.TempDir()
164+ repo, _ := NewFileRepository(tmpDir)
165+
166+ ctx := context.Background()
167+ exists, err := repo.Exists(ctx, "nonexistent")
168+
169+ if err != nil {
170+ t.Fatalf("expected no error, got %v", err)
171+ }
172+
173+ if exists {
174+ t.Error("expected installation to not exist")
175+ }
176+}
177+
178+func TestFileRepository_Save_ContextCancelled(t *testing.T) {
179+ tmpDir := t.TempDir()
180+ repo, _ := NewFileRepository(tmpDir)
181+
182+ ctx, cancel := context.WithCancel(context.Background())
183+ cancel()
184+
185+ err := repo.Save(ctx, "install-001", "state")
186+ if err == nil {
187+ t.Fatal("expected error due to context cancellation")
188+ }
189+}
190+
191+func TestFileRepository_Load_ContextCancelled(t *testing.T) {
192+ tmpDir := t.TempDir()
193+ repo, _ := NewFileRepository(tmpDir)
194+
195+ // Save first
196+ ctx := context.Background()
197+ repo.Save(ctx, "install-001", "state")
198+
199+ // Load with cancelled context
200+ ctx2, cancel := context.WithCancel(context.Background())
201+ cancel()
202+
203+ _, err := repo.Load(ctx2, "install-001")
204+ if err == nil {
205+ t.Fatal("expected error due to context cancellation")
206+ }
207+}
208+
209+func TestFileRepository_Exists_ContextCancelled(t *testing.T) {
210+ tmpDir := t.TempDir()
211+ repo, _ := NewFileRepository(tmpDir)
212+
213+ ctx, cancel := context.WithCancel(context.Background())
214+ cancel()
215+
216+ _, err := repo.Exists(ctx, "install-001")
217+ if err == nil {
218+ t.Fatal("expected error due to context cancellation")
219+ }
220+}
221+
222+func TestFileRepository_MultipleInstallations(t *testing.T) {
223+ tmpDir := t.TempDir()
224+ repo, _ := NewFileRepository(tmpDir)
225+
226+ ctx := context.Background()
227+
228+ // Save multiple installations
229+ repo.Save(ctx, "install-001", "state 1")
230+ repo.Save(ctx, "install-002", "state 2")
231+ repo.Save(ctx, "install-003", "state 3")
232+
233+ // Load each one
234+ state1, _ := repo.Load(ctx, "install-001")
235+ state2, _ := repo.Load(ctx, "install-002")
236+ state3, _ := repo.Load(ctx, "install-003")
237+
238+ if state1 != "state 1" {
239+ t.Errorf("expected 'state 1', got %s", state1)
240+ }
241+ if state2 != "state 2" {
242+ t.Errorf("expected 'state 2', got %s", state2)
243+ }
244+ if state3 != "state 3" {
245+ t.Errorf("expected 'state 3', got %s", state3)
246+ }
247+}
248+
249+func TestFileRepository_Save_ContextTimeout(t *testing.T) {
250+ tmpDir := t.TempDir()
251+ repo, _ := NewFileRepository(tmpDir)
252+
253+ ctx, cancel := context.WithTimeout(context.Background(), 1*time.Nanosecond)
254+ defer cancel()
255+
256+ // Give it time to timeout
257+ time.Sleep(10 * time.Millisecond)
258+
259+ err := repo.Save(ctx, "install-001", "state")
260+ if err == nil {
261+ t.Fatal("expected error due to context timeout")
262+ }
263+}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 1

Tags 0

    ↑↓ moveenter openesc close