detached c7ad5bd

fix(repos): build AUR helper as user to avoid permission issues

commit
c7ad5bd1dca1a1322b796cbf0f2911fd7878a224
author
bnema · · 2025-10-27 17:18 UTC
committer
bnema · · 2025-10-27 18:49 UTC
parent
5245050
signature
unsigned
29 files · +3640 -326
M .gitignore +1 -0
@@ -22,6 +22,7 @@ coverage.out
2222 /*.md
2323 !README.md
2424 !CHANGELOG.md
25+!LICENSE.md
2526 !RELEASING.md
2627
2728
M Makefile +5 -0
@@ -3,6 +3,11 @@
33
44 # Get version from git tag or use dev
55 VERSION := $(shell git describe --tags --always --dirty 2>/dev/null || echo "dev")
6+# Append branch name if not on main
7+CURRENT_BRANCH := $(shell git rev-parse --abbrev-ref HEAD 2>/dev/null)
8+ifeq ($(CURRENT_BRANCH),dev)
9+ VERSION := $(VERSION)-dev
10+endif
611 LDFLAGS := -ldflags "-X main.version=$(VERSION)"
712 LDFLAGS_STRIP := -ldflags "-X main.version=$(VERSION) -s -w"
813
M cmd/archup-installer/main.go +16 -8
@@ -8,6 +8,7 @@ import (
88 tea "github.com/charmbracelet/bubbletea"
99 "github.com/bnema/archup/internal/cleanup"
1010 "github.com/bnema/archup/internal/config"
11+ "github.com/bnema/archup/internal/interfaces"
1112 "github.com/bnema/archup/internal/logger"
1213 "github.com/bnema/archup/internal/phases"
1314 "github.com/bnema/archup/internal/ui"
@@ -61,16 +62,23 @@ func main() {
6162 // Initialize orchestrator
6263 orchestrator := phases.NewOrchestrator(cfg, config.DefaultLogPath)
6364
65+ // Create concrete implementations of interfaces for dependency injection
66+ fs := &interfaces.DefaultFileSystem{}
67+ httpClient := &interfaces.DefaultHTTPClient{}
68+ sysExec := &interfaces.DefaultSystemExecutor{}
69+ chrExec := &interfaces.DefaultChrootExecutor{}
70+ cmdExec := &interfaces.DefaultCommandExecutor{}
71+
6472 // Register all phases with logger
6573 phasesToRegister := []phases.Phase{
66- phases.NewBootstrapPhase(cfg, log),
67- phases.NewPreflightPhase(cfg, log),
68- phases.NewPartitioningPhase(cfg, log),
69- phases.NewBaseInstallPhase(cfg, log),
70- phases.NewConfigPhase(cfg, log),
71- phases.NewBootPhase(cfg, log),
72- phases.NewReposPhase(cfg, log),
73- phases.NewPostInstallPhase(cfg, log),
74+ phases.NewBootstrapPhase(cfg, log, httpClient, fs),
75+ phases.NewPreflightPhase(cfg, log, fs, cmdExec),
76+ phases.NewPartitioningPhase(cfg, log, sysExec),
77+ phases.NewBaseInstallPhase(cfg, log, fs, sysExec),
78+ phases.NewConfigPhase(cfg, log, fs, sysExec, chrExec),
79+ phases.NewBootPhase(cfg, log, fs, sysExec, chrExec),
80+ phases.NewReposPhase(cfg, log, fs, sysExec, chrExec),
81+ phases.NewPostInstallPhase(cfg, log, fs, httpClient, sysExec, chrExec),
7482 }
7583
7684 for _, phase := range phasesToRegister {
M go.mod +1 -0
@@ -7,6 +7,7 @@ require (
77 github.com/charmbracelet/bubbletea v1.3.10
88 github.com/charmbracelet/huh v0.8.0
99 github.com/charmbracelet/lipgloss v1.1.0
10+ go.uber.org/mock v0.6.0
1011 )
1112
1213 require (
M go.sum +2 -0 generated file: collapsed
A internal/interfaces/interfaces.go +140 -0
@@ -0,0 +1,140 @@
1+package interfaces
2+
3+import (
4+ "io"
5+ "net/http"
6+ "os"
7+ "os/exec"
8+
9+ "github.com/bnema/archup/internal/system"
10+)
11+
12+// FileSystem abstracts file system operations for testing
13+type FileSystem interface {
14+ Stat(name string) (os.FileInfo, error)
15+ IsNotExist(err error) bool
16+ Open(name string) (io.ReadCloser, error)
17+ ReadFile(filename string) ([]byte, error)
18+ WriteFile(filename string, data []byte, perm os.FileMode) error
19+ Create(name string) (io.WriteCloser, error)
20+ MkdirAll(path string, perm os.FileMode) error
21+ RemoveAll(path string) error
22+ Chmod(name string, mode os.FileMode) error
23+}
24+
25+// CommandExecutor abstracts command execution
26+type CommandExecutor interface {
27+ Execute(name string, args ...string) ([]byte, error)
28+}
29+
30+// SystemExecutor abstracts system package functions
31+type SystemExecutor interface {
32+ RunSimple(command string, args ...string) system.CommandResult
33+ DetectCPUInfo() (*system.CPUInfo, error)
34+}
35+
36+// ChrootExecutor abstracts chroot operations
37+type ChrootExecutor interface {
38+ ChrootExec(logPath, mountPoint, command string, args ...string) error
39+ ChrootSystemctl(logPath, mountPoint, action, service string) error
40+ ChrootExecWithStdin(logPath, mountPoint, command, stdin string) error
41+ ChrootPacman(logPath, mountPoint, operation string, packages ...string) error
42+ DownloadAndInstallPackages(logPath, chrootPath string, urls ...string) error
43+}
44+
45+// HTTPClient abstracts HTTP operations
46+type HTTPClient interface {
47+ Get(url string) (*http.Response, error)
48+}
49+
50+// ConfigSaver abstracts config save operation
51+type ConfigSaver interface {
52+ Save() error
53+}
54+
55+// DefaultFileSystem is the concrete implementation using OS functions
56+type DefaultFileSystem struct{}
57+
58+func (d *DefaultFileSystem) Stat(name string) (os.FileInfo, error) {
59+ return os.Stat(name)
60+}
61+
62+func (d *DefaultFileSystem) IsNotExist(err error) bool {
63+ return os.IsNotExist(err)
64+}
65+
66+func (d *DefaultFileSystem) Open(name string) (io.ReadCloser, error) {
67+ return os.Open(name)
68+}
69+
70+func (d *DefaultFileSystem) ReadFile(filename string) ([]byte, error) {
71+ return os.ReadFile(filename)
72+}
73+
74+func (d *DefaultFileSystem) WriteFile(filename string, data []byte, perm os.FileMode) error {
75+ return os.WriteFile(filename, data, perm)
76+}
77+
78+func (d *DefaultFileSystem) Create(name string) (io.WriteCloser, error) {
79+ return os.Create(name)
80+}
81+
82+func (d *DefaultFileSystem) MkdirAll(path string, perm os.FileMode) error {
83+ return os.MkdirAll(path, perm)
84+}
85+
86+func (d *DefaultFileSystem) RemoveAll(path string) error {
87+ return os.RemoveAll(path)
88+}
89+
90+func (d *DefaultFileSystem) Chmod(name string, mode os.FileMode) error {
91+ return os.Chmod(name, mode)
92+}
93+
94+// DefaultCommandExecutor is the concrete implementation using os/exec
95+type DefaultCommandExecutor struct{}
96+
97+func (d *DefaultCommandExecutor) Execute(name string, args ...string) ([]byte, error) {
98+ return exec.Command(name, args...).Output()
99+}
100+
101+// DefaultHTTPClient is the concrete implementation using net/http
102+type DefaultHTTPClient struct{}
103+
104+func (d *DefaultHTTPClient) Get(url string) (*http.Response, error) {
105+ return http.Get(url)
106+}
107+
108+// DefaultSystemExecutor is the concrete implementation using system functions
109+type DefaultSystemExecutor struct{}
110+
111+func (d *DefaultSystemExecutor) RunSimple(command string, args ...string) system.CommandResult {
112+ return system.RunSimple(command, args...)
113+}
114+
115+func (d *DefaultSystemExecutor) DetectCPUInfo() (*system.CPUInfo, error) {
116+ return system.DetectCPUInfo()
117+}
118+
119+// DefaultChrootExecutor is the concrete implementation using chroot functions
120+type DefaultChrootExecutor struct{}
121+
122+func (d *DefaultChrootExecutor) ChrootExec(logPath, mountPoint, command string, args ...string) error {
123+ return system.ChrootExec(logPath, mountPoint, command, args...)
124+}
125+
126+func (d *DefaultChrootExecutor) ChrootSystemctl(logPath, mountPoint, action, service string) error {
127+ return system.ChrootSystemctl(logPath, mountPoint, action, service)
128+}
129+
130+func (d *DefaultChrootExecutor) ChrootExecWithStdin(logPath, mountPoint, command, stdin string) error {
131+ return system.ChrootExecWithStdin(logPath, mountPoint, command, stdin)
132+}
133+
134+func (d *DefaultChrootExecutor) ChrootPacman(logPath, mountPoint, operation string, packages ...string) error {
135+ return system.ChrootPacman(logPath, mountPoint, operation, packages...)
136+}
137+
138+func (d *DefaultChrootExecutor) DownloadAndInstallPackages(logPath, chrootPath string, urls ...string) error {
139+ return system.DownloadAndInstallPackages(logPath, chrootPath, urls...)
140+}
A internal/interfaces/mocks/mock_interfaces.go +462 -0
@@ -0,0 +1,462 @@
1+// Code generated by MockGen. DO NOT EDIT.
2+// Source: /home/brice/dev/projects/archup/internal/interfaces/interfaces.go
3+//
4+// Generated by this command:
5+//
6+// mockgen -source=/home/brice/dev/projects/archup/internal/interfaces/interfaces.go -destination=/home/brice/dev/projects/archup/internal/interfaces/mocks/mock_interfaces.go -package=mocks
7+//
8+
9+// Package mocks is a generated GoMock package.
10+package mocks
11+
12+import (
13+ io "io"
14+ http "net/http"
15+ os "os"
16+ reflect "reflect"
17+
18+ system "github.com/bnema/archup/internal/system"
19+ gomock "go.uber.org/mock/gomock"
20+)
21+
22+// MockFileSystem is a mock of FileSystem interface.
23+type MockFileSystem struct {
24+ ctrl *gomock.Controller
25+ recorder *MockFileSystemMockRecorder
26+ isgomock struct{}
27+}
28+
29+// MockFileSystemMockRecorder is the mock recorder for MockFileSystem.
30+type MockFileSystemMockRecorder struct {
31+ mock *MockFileSystem
32+}
33+
34+// NewMockFileSystem creates a new mock instance.
35+func NewMockFileSystem(ctrl *gomock.Controller) *MockFileSystem {
36+ mock := &MockFileSystem{ctrl: ctrl}
37+ mock.recorder = &MockFileSystemMockRecorder{mock}
38+ return mock
39+}
40+
41+// EXPECT returns an object that allows the caller to indicate expected use.
42+func (m *MockFileSystem) EXPECT() *MockFileSystemMockRecorder {
43+ return m.recorder
44+}
45+
46+// Chmod mocks base method.
47+func (m *MockFileSystem) Chmod(name string, mode os.FileMode) error {
48+ m.ctrl.T.Helper()
49+ ret := m.ctrl.Call(m, "Chmod", name, mode)
50+ ret0, _ := ret[0].(error)
51+ return ret0
52+}
53+
54+// Chmod indicates an expected call of Chmod.
55+func (mr *MockFileSystemMockRecorder) Chmod(name, mode any) *gomock.Call {
56+ mr.mock.ctrl.T.Helper()
57+ return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Chmod", reflect.TypeOf((*MockFileSystem)(nil).Chmod), name, mode)
58+}
59+
60+// Create mocks base method.
61+func (m *MockFileSystem) Create(name string) (io.WriteCloser, error) {
62+ m.ctrl.T.Helper()
63+ ret := m.ctrl.Call(m, "Create", name)
64+ ret0, _ := ret[0].(io.WriteCloser)
65+ ret1, _ := ret[1].(error)
66+ return ret0, ret1
67+}
68+
69+// Create indicates an expected call of Create.
70+func (mr *MockFileSystemMockRecorder) Create(name any) *gomock.Call {
71+ mr.mock.ctrl.T.Helper()
72+ return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Create", reflect.TypeOf((*MockFileSystem)(nil).Create), name)
73+}
74+
75+// IsNotExist mocks base method.
76+func (m *MockFileSystem) IsNotExist(err error) bool {
77+ m.ctrl.T.Helper()
78+ ret := m.ctrl.Call(m, "IsNotExist", err)
79+ ret0, _ := ret[0].(bool)
80+ return ret0
81+}
82+
83+// IsNotExist indicates an expected call of IsNotExist.
84+func (mr *MockFileSystemMockRecorder) IsNotExist(err any) *gomock.Call {
85+ mr.mock.ctrl.T.Helper()
86+ return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "IsNotExist", reflect.TypeOf((*MockFileSystem)(nil).IsNotExist), err)
87+}
88+
89+// MkdirAll mocks base method.
90+func (m *MockFileSystem) MkdirAll(path string, perm os.FileMode) error {
91+ m.ctrl.T.Helper()
92+ ret := m.ctrl.Call(m, "MkdirAll", path, perm)
93+ ret0, _ := ret[0].(error)
94+ return ret0
95+}
96+
97+// MkdirAll indicates an expected call of MkdirAll.
98+func (mr *MockFileSystemMockRecorder) MkdirAll(path, perm any) *gomock.Call {
99+ mr.mock.ctrl.T.Helper()
100+ return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "MkdirAll", reflect.TypeOf((*MockFileSystem)(nil).MkdirAll), path, perm)
101+}
102+
103+// Open mocks base method.
104+func (m *MockFileSystem) Open(name string) (io.ReadCloser, error) {
105+ m.ctrl.T.Helper()
106+ ret := m.ctrl.Call(m, "Open", name)
107+ ret0, _ := ret[0].(io.ReadCloser)
108+ ret1, _ := ret[1].(error)
109+ return ret0, ret1
110+}
111+
112+// Open indicates an expected call of Open.
113+func (mr *MockFileSystemMockRecorder) Open(name any) *gomock.Call {
114+ mr.mock.ctrl.T.Helper()
115+ return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Open", reflect.TypeOf((*MockFileSystem)(nil).Open), name)
116+}
117+
118+// ReadFile mocks base method.
119+func (m *MockFileSystem) ReadFile(filename string) ([]byte, error) {
120+ m.ctrl.T.Helper()
121+ ret := m.ctrl.Call(m, "ReadFile", filename)
122+ ret0, _ := ret[0].([]byte)
123+ ret1, _ := ret[1].(error)
124+ return ret0, ret1
125+}
126+
127+// ReadFile indicates an expected call of ReadFile.
128+func (mr *MockFileSystemMockRecorder) ReadFile(filename any) *gomock.Call {
129+ mr.mock.ctrl.T.Helper()
130+ return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ReadFile", reflect.TypeOf((*MockFileSystem)(nil).ReadFile), filename)
131+}
132+
133+// RemoveAll mocks base method.
134+func (m *MockFileSystem) RemoveAll(path string) error {
135+ m.ctrl.T.Helper()
136+ ret := m.ctrl.Call(m, "RemoveAll", path)
137+ ret0, _ := ret[0].(error)
138+ return ret0
139+}
140+
141+// RemoveAll indicates an expected call of RemoveAll.
142+func (mr *MockFileSystemMockRecorder) RemoveAll(path any) *gomock.Call {
143+ mr.mock.ctrl.T.Helper()
144+ return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "RemoveAll", reflect.TypeOf((*MockFileSystem)(nil).RemoveAll), path)
145+}
146+
147+// Stat mocks base method.
148+func (m *MockFileSystem) Stat(name string) (os.FileInfo, error) {
149+ m.ctrl.T.Helper()
150+ ret := m.ctrl.Call(m, "Stat", name)
151+ ret0, _ := ret[0].(os.FileInfo)
152+ ret1, _ := ret[1].(error)
153+ return ret0, ret1
154+}
155+
156+// Stat indicates an expected call of Stat.
157+func (mr *MockFileSystemMockRecorder) Stat(name any) *gomock.Call {
158+ mr.mock.ctrl.T.Helper()
159+ return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Stat", reflect.TypeOf((*MockFileSystem)(nil).Stat), name)
160+}
161+
162+// WriteFile mocks base method.
163+func (m *MockFileSystem) WriteFile(filename string, data []byte, perm os.FileMode) error {
164+ m.ctrl.T.Helper()
165+ ret := m.ctrl.Call(m, "WriteFile", filename, data, perm)
166+ ret0, _ := ret[0].(error)
167+ return ret0
168+}
169+
170+// WriteFile indicates an expected call of WriteFile.
171+func (mr *MockFileSystemMockRecorder) WriteFile(filename, data, perm any) *gomock.Call {
172+ mr.mock.ctrl.T.Helper()
173+ return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "WriteFile", reflect.TypeOf((*MockFileSystem)(nil).WriteFile), filename, data, perm)
174+}
175+
176+// MockCommandExecutor is a mock of CommandExecutor interface.
177+type MockCommandExecutor struct {
178+ ctrl *gomock.Controller
179+ recorder *MockCommandExecutorMockRecorder
180+ isgomock struct{}
181+}
182+
183+// MockCommandExecutorMockRecorder is the mock recorder for MockCommandExecutor.
184+type MockCommandExecutorMockRecorder struct {
185+ mock *MockCommandExecutor
186+}
187+
188+// NewMockCommandExecutor creates a new mock instance.
189+func NewMockCommandExecutor(ctrl *gomock.Controller) *MockCommandExecutor {
190+ mock := &MockCommandExecutor{ctrl: ctrl}
191+ mock.recorder = &MockCommandExecutorMockRecorder{mock}
192+ return mock
193+}
194+
195+// EXPECT returns an object that allows the caller to indicate expected use.
196+func (m *MockCommandExecutor) EXPECT() *MockCommandExecutorMockRecorder {
197+ return m.recorder
198+}
199+
200+// Execute mocks base method.
201+func (m *MockCommandExecutor) Execute(name string, args ...string) ([]byte, error) {
202+ m.ctrl.T.Helper()
203+ varargs := []any{name}
204+ for _, a := range args {
205+ varargs = append(varargs, a)
206+ }
207+ ret := m.ctrl.Call(m, "Execute", varargs...)
208+ ret0, _ := ret[0].([]byte)
209+ ret1, _ := ret[1].(error)
210+ return ret0, ret1
211+}
212+
213+// Execute indicates an expected call of Execute.
214+func (mr *MockCommandExecutorMockRecorder) Execute(name any, args ...any) *gomock.Call {
215+ mr.mock.ctrl.T.Helper()
216+ varargs := append([]any{name}, args...)
217+ return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Execute", reflect.TypeOf((*MockCommandExecutor)(nil).Execute), varargs...)
218+}
219+
220+// MockSystemExecutor is a mock of SystemExecutor interface.
221+type MockSystemExecutor struct {
222+ ctrl *gomock.Controller
223+ recorder *MockSystemExecutorMockRecorder
224+ isgomock struct{}
225+}
226+
227+// MockSystemExecutorMockRecorder is the mock recorder for MockSystemExecutor.
228+type MockSystemExecutorMockRecorder struct {
229+ mock *MockSystemExecutor
230+}
231+
232+// NewMockSystemExecutor creates a new mock instance.
233+func NewMockSystemExecutor(ctrl *gomock.Controller) *MockSystemExecutor {
234+ mock := &MockSystemExecutor{ctrl: ctrl}
235+ mock.recorder = &MockSystemExecutorMockRecorder{mock}
236+ return mock
237+}
238+
239+// EXPECT returns an object that allows the caller to indicate expected use.
240+func (m *MockSystemExecutor) EXPECT() *MockSystemExecutorMockRecorder {
241+ return m.recorder
242+}
243+
244+// DetectCPUInfo mocks base method.
245+func (m *MockSystemExecutor) DetectCPUInfo() (*system.CPUInfo, error) {
246+ m.ctrl.T.Helper()
247+ ret := m.ctrl.Call(m, "DetectCPUInfo")
248+ ret0, _ := ret[0].(*system.CPUInfo)
249+ ret1, _ := ret[1].(error)
250+ return ret0, ret1
251+}
252+
253+// DetectCPUInfo indicates an expected call of DetectCPUInfo.
254+func (mr *MockSystemExecutorMockRecorder) DetectCPUInfo() *gomock.Call {
255+ mr.mock.ctrl.T.Helper()
256+ return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DetectCPUInfo", reflect.TypeOf((*MockSystemExecutor)(nil).DetectCPUInfo))
257+}
258+
259+// RunSimple mocks base method.
260+func (m *MockSystemExecutor) RunSimple(command string, args ...string) system.CommandResult {
261+ m.ctrl.T.Helper()
262+ varargs := []any{command}
263+ for _, a := range args {
264+ varargs = append(varargs, a)
265+ }
266+ ret := m.ctrl.Call(m, "RunSimple", varargs...)
267+ ret0, _ := ret[0].(system.CommandResult)
268+ return ret0
269+}
270+
271+// RunSimple indicates an expected call of RunSimple.
272+func (mr *MockSystemExecutorMockRecorder) RunSimple(command any, args ...any) *gomock.Call {
273+ mr.mock.ctrl.T.Helper()
274+ varargs := append([]any{command}, args...)
275+ return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "RunSimple", reflect.TypeOf((*MockSystemExecutor)(nil).RunSimple), varargs...)
276+}
277+
278+// MockChrootExecutor is a mock of ChrootExecutor interface.
279+type MockChrootExecutor struct {
280+ ctrl *gomock.Controller
281+ recorder *MockChrootExecutorMockRecorder
282+ isgomock struct{}
283+}
284+
285+// MockChrootExecutorMockRecorder is the mock recorder for MockChrootExecutor.
286+type MockChrootExecutorMockRecorder struct {
287+ mock *MockChrootExecutor
288+}
289+
290+// NewMockChrootExecutor creates a new mock instance.
291+func NewMockChrootExecutor(ctrl *gomock.Controller) *MockChrootExecutor {
292+ mock := &MockChrootExecutor{ctrl: ctrl}
293+ mock.recorder = &MockChrootExecutorMockRecorder{mock}
294+ return mock
295+}
296+
297+// EXPECT returns an object that allows the caller to indicate expected use.
298+func (m *MockChrootExecutor) EXPECT() *MockChrootExecutorMockRecorder {
299+ return m.recorder
300+}
301+
302+// ChrootExec mocks base method.
303+func (m *MockChrootExecutor) ChrootExec(logPath, mountPoint, command string, args ...string) error {
304+ m.ctrl.T.Helper()
305+ varargs := []any{logPath, mountPoint, command}
306+ for _, a := range args {
307+ varargs = append(varargs, a)
308+ }
309+ ret := m.ctrl.Call(m, "ChrootExec", varargs...)
310+ ret0, _ := ret[0].(error)
311+ return ret0
312+}
313+
314+// ChrootExec indicates an expected call of ChrootExec.
315+func (mr *MockChrootExecutorMockRecorder) ChrootExec(logPath, mountPoint, command any, args ...any) *gomock.Call {
316+ mr.mock.ctrl.T.Helper()
317+ varargs := append([]any{logPath, mountPoint, command}, args...)
318+ return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ChrootExec", reflect.TypeOf((*MockChrootExecutor)(nil).ChrootExec), varargs...)
319+}
320+
321+// ChrootExecWithStdin mocks base method.
322+func (m *MockChrootExecutor) ChrootExecWithStdin(logPath, mountPoint, command, stdin string) error {
323+ m.ctrl.T.Helper()
324+ ret := m.ctrl.Call(m, "ChrootExecWithStdin", logPath, mountPoint, command, stdin)
325+ ret0, _ := ret[0].(error)
326+ return ret0
327+}
328+
329+// ChrootExecWithStdin indicates an expected call of ChrootExecWithStdin.
330+func (mr *MockChrootExecutorMockRecorder) ChrootExecWithStdin(logPath, mountPoint, command, stdin any) *gomock.Call {
331+ mr.mock.ctrl.T.Helper()
332+ return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ChrootExecWithStdin", reflect.TypeOf((*MockChrootExecutor)(nil).ChrootExecWithStdin), logPath, mountPoint, command, stdin)
333+}
334+
335+// ChrootPacman mocks base method.
336+func (m *MockChrootExecutor) ChrootPacman(logPath, mountPoint, operation string, packages ...string) error {
337+ m.ctrl.T.Helper()
338+ varargs := []any{logPath, mountPoint, operation}
339+ for _, a := range packages {
340+ varargs = append(varargs, a)
341+ }
342+ ret := m.ctrl.Call(m, "ChrootPacman", varargs...)
343+ ret0, _ := ret[0].(error)
344+ return ret0
345+}
346+
347+// ChrootPacman indicates an expected call of ChrootPacman.
348+func (mr *MockChrootExecutorMockRecorder) ChrootPacman(logPath, mountPoint, operation any, packages ...any) *gomock.Call {
349+ mr.mock.ctrl.T.Helper()
350+ varargs := append([]any{logPath, mountPoint, operation}, packages...)
351+ return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ChrootPacman", reflect.TypeOf((*MockChrootExecutor)(nil).ChrootPacman), varargs...)
352+}
353+
354+// ChrootSystemctl mocks base method.
355+func (m *MockChrootExecutor) ChrootSystemctl(logPath, mountPoint, action, service string) error {
356+ m.ctrl.T.Helper()
357+ ret := m.ctrl.Call(m, "ChrootSystemctl", logPath, mountPoint, action, service)
358+ ret0, _ := ret[0].(error)
359+ return ret0
360+}
361+
362+// ChrootSystemctl indicates an expected call of ChrootSystemctl.
363+func (mr *MockChrootExecutorMockRecorder) ChrootSystemctl(logPath, mountPoint, action, service any) *gomock.Call {
364+ mr.mock.ctrl.T.Helper()
365+ return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ChrootSystemctl", reflect.TypeOf((*MockChrootExecutor)(nil).ChrootSystemctl), logPath, mountPoint, action, service)
366+}
367+
368+// DownloadAndInstallPackages mocks base method.
369+func (m *MockChrootExecutor) DownloadAndInstallPackages(logPath, chrootPath string, urls ...string) error {
370+ m.ctrl.T.Helper()
371+ varargs := []any{logPath, chrootPath}
372+ for _, a := range urls {
373+ varargs = append(varargs, a)
374+ }
375+ ret := m.ctrl.Call(m, "DownloadAndInstallPackages", varargs...)
376+ ret0, _ := ret[0].(error)
377+ return ret0
378+}
379+
380+// DownloadAndInstallPackages indicates an expected call of DownloadAndInstallPackages.
381+func (mr *MockChrootExecutorMockRecorder) DownloadAndInstallPackages(logPath, chrootPath any, urls ...any) *gomock.Call {
382+ mr.mock.ctrl.T.Helper()
383+ varargs := append([]any{logPath, chrootPath}, urls...)
384+ return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DownloadAndInstallPackages", reflect.TypeOf((*MockChrootExecutor)(nil).DownloadAndInstallPackages), varargs...)
385+}
386+
387+// MockHTTPClient is a mock of HTTPClient interface.
388+type MockHTTPClient struct {
389+ ctrl *gomock.Controller
390+ recorder *MockHTTPClientMockRecorder
391+ isgomock struct{}
392+}
393+
394+// MockHTTPClientMockRecorder is the mock recorder for MockHTTPClient.
395+type MockHTTPClientMockRecorder struct {
396+ mock *MockHTTPClient
397+}
398+
399+// NewMockHTTPClient creates a new mock instance.
400+func NewMockHTTPClient(ctrl *gomock.Controller) *MockHTTPClient {
401+ mock := &MockHTTPClient{ctrl: ctrl}
402+ mock.recorder = &MockHTTPClientMockRecorder{mock}
403+ return mock
404+}
405+
406+// EXPECT returns an object that allows the caller to indicate expected use.
407+func (m *MockHTTPClient) EXPECT() *MockHTTPClientMockRecorder {
408+ return m.recorder
409+}
410+
411+// Get mocks base method.
412+func (m *MockHTTPClient) Get(url string) (*http.Response, error) {
413+ m.ctrl.T.Helper()
414+ ret := m.ctrl.Call(m, "Get", url)
415+ ret0, _ := ret[0].(*http.Response)
416+ ret1, _ := ret[1].(error)
417+ return ret0, ret1
418+}
419+
420+// Get indicates an expected call of Get.
421+func (mr *MockHTTPClientMockRecorder) Get(url any) *gomock.Call {
422+ mr.mock.ctrl.T.Helper()
423+ return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Get", reflect.TypeOf((*MockHTTPClient)(nil).Get), url)
424+}
425+
426+// MockConfigSaver is a mock of ConfigSaver interface.
427+type MockConfigSaver struct {
428+ ctrl *gomock.Controller
429+ recorder *MockConfigSaverMockRecorder
430+ isgomock struct{}
431+}
432+
433+// MockConfigSaverMockRecorder is the mock recorder for MockConfigSaver.
434+type MockConfigSaverMockRecorder struct {
435+ mock *MockConfigSaver
436+}
437+
438+// NewMockConfigSaver creates a new mock instance.
439+func NewMockConfigSaver(ctrl *gomock.Controller) *MockConfigSaver {
440+ mock := &MockConfigSaver{ctrl: ctrl}
441+ mock.recorder = &MockConfigSaverMockRecorder{mock}
442+ return mock
443+}
444+
445+// EXPECT returns an object that allows the caller to indicate expected use.
446+func (m *MockConfigSaver) EXPECT() *MockConfigSaverMockRecorder {
447+ return m.recorder
448+}
449+
450+// Save mocks base method.
451+func (m *MockConfigSaver) Save() error {
452+ m.ctrl.T.Helper()
453+ ret := m.ctrl.Call(m, "Save")
454+ ret0, _ := ret[0].(error)
455+ return ret0
456+}
457+
458+// Save indicates an expected call of Save.
459+func (mr *MockConfigSaverMockRecorder) Save() *gomock.Call {
460+ mr.mock.ctrl.T.Helper()
461+ return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Save", reflect.TypeOf((*MockConfigSaver)(nil).Save))
462+}
M internal/phases/base.go +12 -9
@@ -3,31 +3,34 @@ package phases
33 import (
44 "bufio"
55 "fmt"
6- "os"
76 "strings"
87
98 "github.com/bnema/archup/internal/config"
9+ "github.com/bnema/archup/internal/interfaces"
1010 "github.com/bnema/archup/internal/logger"
11- "github.com/bnema/archup/internal/system"
1211 )
1312
1413 // BaseInstallPhase handles base system installation
1514 type BaseInstallPhase struct {
1615 *BasePhase
16+ fs interfaces.FileSystem
17+ sysExec interfaces.SystemExecutor
1718 packages []string
1819 }
1920
2021 // NewBaseInstallPhase creates a new base installation phase
21-func NewBaseInstallPhase(cfg *config.Config, log *logger.Logger) *BaseInstallPhase {
22+func NewBaseInstallPhase(cfg *config.Config, log *logger.Logger, fs interfaces.FileSystem, sysExec interfaces.SystemExecutor) *BaseInstallPhase {
2223 return &BaseInstallPhase{
2324 BasePhase: NewBasePhase("base", "Base System Installation", cfg, log),
25+ fs: fs,
26+ sysExec: sysExec,
2427 }
2528 }
2629
2730 // PreCheck validates prerequisites
2831 func (p *BaseInstallPhase) PreCheck() error {
2932 // Verify /mnt is mounted
30- result := system.RunSimple("mountpoint", "-q", "/mnt")
33+ result := p.sysExec.RunSimple("mountpoint", "-q", "/mnt")
3134 switch {
3235 case result.ExitCode != 0:
3336 return fmt.Errorf("/mnt is not mounted")
@@ -103,7 +106,7 @@ func (p *BaseInstallPhase) detectCPU(progressChan chan<- ProgressUpdate) error {
103106 p.SendOutput(progressChan, "Detecting CPU vendor...")
104107
105108 // Read /proc/cpuinfo to detect vendor
106- file, err := os.Open("/proc/cpuinfo")
109+ file, err := p.fs.Open("/proc/cpuinfo")
107110 switch {
108111 case err != nil:
109112 return fmt.Errorf("failed to read /proc/cpuinfo: %w", err)
@@ -208,7 +211,7 @@ func (p *BaseInstallPhase) loadBasePackages() ([]string, error) {
208211 // Use DefaultInstallDir directly to match where bootstrap downloads files
209212 packageFile := config.DefaultInstallDir + "/" + config.BasePackagesFile
210213
211- file, err := os.Open(packageFile)
214+ file, err := p.fs.Open(packageFile)
212215 switch {
213216 case err != nil:
214217 return nil, fmt.Errorf("failed to open %s: %w", packageFile, err)
@@ -244,7 +247,7 @@ func (p *BaseInstallPhase) loadBasePackages() ([]string, error) {
244247 func (p *BaseInstallPhase) generateFstab(progressChan chan<- ProgressUpdate) error {
245248 p.SendOutput(progressChan, "Generating fstab with UUIDs...")
246249
247- result := system.RunSimple("sh", "-c", "genfstab -U /mnt >> /mnt/etc/fstab")
250+ result := p.sysExec.RunSimple("sh", "-c", "genfstab -U /mnt >> /mnt/etc/fstab")
248251 switch {
249252 case result.ExitCode != 0:
250253 return fmt.Errorf("genfstab failed: %w", result.Error)
@@ -257,14 +260,14 @@ func (p *BaseInstallPhase) generateFstab(progressChan chan<- ProgressUpdate) err
257260 // PostCheck validates installation
258261 func (p *BaseInstallPhase) PostCheck() error {
259262 // Check if base system exists
260- result := system.RunSimple("test", "-d", "/mnt/usr")
263+ result := p.sysExec.RunSimple("test", "-d", "/mnt/usr")
261264 switch {
262265 case result.ExitCode != 0:
263266 return fmt.Errorf("/mnt/usr does not exist")
264267 }
265268
266269 // Check if fstab exists
267- result = system.RunSimple("test", "-f", "/mnt/etc/fstab")
270+ result = p.sysExec.RunSimple("test", "-f", "/mnt/etc/fstab")
268271 switch {
269272 case result.ExitCode != 0:
270273 return fmt.Errorf("/mnt/etc/fstab was not created")
M internal/phases/base_test.go +3 -13
@@ -62,7 +62,7 @@ func TestBaseInstallPhasePreCheck(t *testing.T) {
6262 return
6363 }
6464 if tt.wantErr && err != nil {
65- if errMsg := err.Error(); tt.errContains != "" && !containsSubstringBaseTest(errMsg, tt.errContains) {
65+ if errMsg := err.Error(); tt.errContains != "" && !containsSubstring(errMsg, tt.errContains) {
6666 t.Errorf("PreCheck() error = %q, should contain %q", errMsg, tt.errContains)
6767 }
6868 }
@@ -284,7 +284,7 @@ func TestBaseInstallPhaseGenerateFstab(t *testing.T) {
284284 return
285285 }
286286 if tt.wantErr && err != nil {
287- if errMsg := err.Error(); tt.errContains != "" && !containsSubstringBaseTest(errMsg, tt.errContains) {
287+ if errMsg := err.Error(); tt.errContains != "" && !containsSubstring(errMsg, tt.errContains) {
288288 t.Errorf("generateFstab() error = %q, should contain %q", errMsg, tt.errContains)
289289 }
290290 }
@@ -366,7 +366,7 @@ func TestBaseInstallPhasePostCheck(t *testing.T) {
366366 return
367367 }
368368 if tt.wantErr && err != nil {
369- if errMsg := err.Error(); tt.errContains != "" && !containsSubstringBaseTest(errMsg, tt.errContains) {
369+ if errMsg := err.Error(); tt.errContains != "" && !containsSubstring(errMsg, tt.errContains) {
370370 t.Errorf("PostCheck() error = %q, should contain %q", errMsg, tt.errContains)
371371 }
372372 }
@@ -457,13 +457,3 @@ func TestBaseInstallPhaseCanSkip(t *testing.T) {
457457 t.Error("CanSkip() = true, want false")
458458 }
459459 }
460-
461-// Helper function for substring matching (avoid duplication across test files)
462-func containsSubstringBaseTest(s, substr string) bool {
463- for i := 0; i < len(s)-len(substr)+1; i++ {
464- if s[i:i+len(substr)] == substr {
465- return true
466- }
467- }
468- return false
469-}
M internal/phases/boot.go +25 -19
@@ -2,12 +2,12 @@ package phases
22
33 import (
44 "fmt"
5- "os"
65 "path/filepath"
76 "regexp"
87 "strings"
98
109 "github.com/bnema/archup/internal/config"
10+ "github.com/bnema/archup/internal/interfaces"
1111 "github.com/bnema/archup/internal/logger"
1212 "github.com/bnema/archup/internal/system"
1313 )
@@ -15,26 +15,32 @@ import (
1515 // BootPhase handles bootloader installation
1616 type BootPhase struct {
1717 *BasePhase
18+ fs interfaces.FileSystem
19+ sysExec interfaces.SystemExecutor
20+ chrExec interfaces.ChrootExecutor
1821 }
1922
2023 // NewBootPhase creates a new boot phase
21-func NewBootPhase(cfg *config.Config, log *logger.Logger) *BootPhase {
24+func NewBootPhase(cfg *config.Config, log *logger.Logger, fs interfaces.FileSystem, sysExec interfaces.SystemExecutor, chrExec interfaces.ChrootExecutor) *BootPhase {
2225 return &BootPhase{
2326 BasePhase: NewBasePhase("boot", "Bootloader Installation", cfg, log),
27+ fs: fs,
28+ sysExec: sysExec,
29+ chrExec: chrExec,
2430 }
2531 }
2632
2733 // PreCheck validates boot prerequisites
2834 func (p *BootPhase) PreCheck() error {
2935 // Verify /mnt is mounted
30- result := system.RunSimple("mountpoint", "-q", config.PathMnt)
36+ result := p.sysExec.RunSimple("mountpoint", "-q", config.PathMnt)
3137 switch {
3238 case result.ExitCode != 0:
3339 return fmt.Errorf("%s is not mounted", config.PathMnt)
3440 }
3541
3642 // Verify boot partition is mounted
37- result = system.RunSimple("mountpoint", "-q", config.PathMntBoot)
43+ result = p.sysExec.RunSimple("mountpoint", "-q", config.PathMntBoot)
3844 switch {
3945 case result.ExitCode != 0:
4046 return fmt.Errorf("%s is not mounted", config.PathMntBoot)
@@ -92,7 +98,7 @@ func (p *BootPhase) configureMkinitcpio(progressChan chan<- ProgressUpdate) erro
9298 p.SendOutput(progressChan, "Configuring initramfs...")
9399
94100 // Read current mkinitcpio.conf
95- content, err := os.ReadFile(config.PathMntEtcMkinitcpio)
101+ content, err := p.fs.ReadFile(config.PathMntEtcMkinitcpio)
96102 switch {
97103 case err != nil:
98104 return fmt.Errorf("failed to read mkinitcpio.conf: %w", err)
@@ -114,14 +120,14 @@ func (p *BootPhase) configureMkinitcpio(progressChan chan<- ProgressUpdate) erro
114120 newContent := hooksRegex.ReplaceAllString(string(content), hooks)
115121
116122 // Write updated config
117- switch err := os.WriteFile(config.PathMntEtcMkinitcpio, []byte(newContent), 0644); {
123+ switch err := p.fs.WriteFile(config.PathMntEtcMkinitcpio, []byte(newContent), 0644); {
118124 case err != nil:
119125 return fmt.Errorf("failed to write mkinitcpio.conf: %w", err)
120126 }
121127
122128 // Regenerate initramfs
123129 p.SendOutput(progressChan, "Regenerating initramfs...")
124- switch err := system.ChrootExec(p.logger.LogPath(),config.PathMnt, "mkinitcpio -P"); {
130+ switch err := p.chrExec.ChrootExec(p.logger.LogPath(),config.PathMnt, "mkinitcpio -P"); {
125131 case err != nil:
126132 return fmt.Errorf("failed to regenerate initramfs: %w", err)
127133 }
@@ -137,7 +143,7 @@ func (p *BootPhase) installLimine(progressChan chan<- ProgressUpdate) error {
137143
138144 // Install Limine to disk (BIOS - optional, may fail on UEFI-only)
139145 biosCmd := fmt.Sprintf("limine bios-install %s", p.config.TargetDisk)
140- switch err := system.ChrootExec(p.logger.LogPath(),config.PathMnt, biosCmd); {
146+ switch err := p.chrExec.ChrootExec(p.logger.LogPath(),config.PathMnt, biosCmd); {
141147 case err != nil:
142148 p.SendOutput(progressChan, "[WARN] BIOS installation skipped (UEFI-only system)")
143149 default:
@@ -145,7 +151,7 @@ func (p *BootPhase) installLimine(progressChan chan<- ProgressUpdate) error {
145151 }
146152
147153 // Create Limine directory
148- switch err := os.MkdirAll(config.PathMntBootEFILimine, 0755); {
154+ switch err := p.fs.MkdirAll(config.PathMntBootEFILimine, 0755); {
149155 case err != nil:
150156 return fmt.Errorf("failed to create Limine directory: %w", err)
151157 }
@@ -172,7 +178,7 @@ func (p *BootPhase) configureLimine(progressChan chan<- ProgressUpdate) error {
172178
173179 // Get root partition UUID
174180 rootPartition := p.config.RootPartition
175- result := system.RunSimple("blkid", "-s", "UUID", "-o", "value", rootPartition)
181+ result := p.sysExec.RunSimple("blkid", "-s", "UUID", "-o", "value", rootPartition)
176182 switch {
177183 case result.ExitCode != 0:
178184 return fmt.Errorf("failed to get root partition UUID: %w", result.Error)
@@ -196,7 +202,7 @@ func (p *BootPhase) configureLimine(progressChan chan<- ProgressUpdate) error {
196202 switch {
197203 case p.config.AMDPState != "":
198204 // Detect CPU to get Zen generation for proper kernel params
199- cpuInfo, err := system.DetectCPUInfo()
205+ cpuInfo, err := p.sysExec.DetectCPUInfo()
200206 var amdParams string
201207 if err == nil && cpuInfo.AMDZenGen != nil {
202208 // Use helper function that adds extra params for Zen 2 if needed
@@ -217,7 +223,7 @@ func (p *BootPhase) configureLimine(progressChan chan<- ProgressUpdate) error {
217223
218224 // Read Limine config template
219225 templatePath := p.getInstallPath(config.LimineConfigTemplate)
220- templateContent, err := os.ReadFile(templatePath)
226+ templateContent, err := p.fs.ReadFile(templatePath)
221227 switch {
222228 case err != nil:
223229 return fmt.Errorf("failed to read Limine template: %w", err)
@@ -234,7 +240,7 @@ func (p *BootPhase) configureLimine(progressChan chan<- ProgressUpdate) error {
234240
235241 // Write Limine configuration
236242 limineConfigPath := filepath.Join(config.PathMntBootEFILimine, config.FileLimineConfig)
237- switch err := os.WriteFile(limineConfigPath, []byte(limineConfig), 0644); {
243+ switch err := p.fs.WriteFile(limineConfigPath, []byte(limineConfig), 0644); {
238244 case err != nil:
239245 return fmt.Errorf("failed to write Limine config: %w", err)
240246 }
@@ -263,7 +269,7 @@ func (p *BootPhase) createUEFIEntry(progressChan chan<- ProgressUpdate) error {
263269 config.UEFIBootLoader,
264270 )
265271
266- switch err := system.ChrootExec(p.logger.LogPath(),config.PathMnt, efiCmd); {
272+ switch err := p.chrExec.ChrootExec(p.logger.LogPath(),config.PathMnt, efiCmd); {
267273 case err != nil:
268274 return fmt.Errorf("failed to create UEFI boot entry: %w", err)
269275 }
@@ -293,15 +299,15 @@ func (p *BootPhase) extractPartitionNumber(partPath string) string {
293299 func (p *BootPhase) PostCheck() error {
294300 // Verify Limine bootloader exists
295301 bootloaderPath := filepath.Join(config.PathMntBootEFILimine, config.FileLimineBootloader)
296- switch _, err := os.Stat(bootloaderPath); {
297- case os.IsNotExist(err):
302+ switch _, err := p.fs.Stat(bootloaderPath); {
303+ case p.fs.IsNotExist(err):
298304 return fmt.Errorf("Limine bootloader was not installed")
299305 }
300306
301307 // Verify Limine config exists
302308 configPath := filepath.Join(config.PathMntBootEFILimine, config.FileLimineConfig)
303- switch _, err := os.Stat(configPath); {
304- case os.IsNotExist(err):
309+ switch _, err := p.fs.Stat(configPath); {
310+ case p.fs.IsNotExist(err):
305311 return fmt.Errorf("Limine config was not created")
306312 }
307313
@@ -311,7 +317,7 @@ func (p *BootPhase) PostCheck() error {
311317 // Rollback for boot phase
312318 func (p *BootPhase) Rollback() error {
313319 // Remove Limine files if they exist
314- switch err := os.RemoveAll(config.PathMntBootEFILimine); {
320+ switch err := p.fs.RemoveAll(config.PathMntBootEFILimine); {
315321 case err != nil:
316322 p.SendOutput(nil, fmt.Sprintf("[WARN] Failed to remove Limine directory: %v", err))
317323 }
A internal/phases/boot_test.go +526 -0
@@ -0,0 +1,526 @@
1+package phases
2+
3+import (
4+ "fmt"
5+ "path/filepath"
6+ "testing"
7+
8+ "github.com/bnema/archup/internal/config"
9+ "github.com/bnema/archup/internal/interfaces/mocks"
10+ "github.com/bnema/archup/internal/logger"
11+ "github.com/bnema/archup/internal/system"
12+ "go.uber.org/mock/gomock"
13+)
14+
15+// TestBootPhasePreCheck tests boot prerequisites
16+func TestBootPhasePreCheck(t *testing.T) {
17+ tests := []struct {
18+ name string
19+ setupMocks func(*mocks.MockSystemExecutor)
20+ wantErr bool
21+ errContains string
22+ }{
23+ {
24+ name: "Both /mnt and /mnt/boot mounted",
25+ setupMocks: func(mockSysExec *mocks.MockSystemExecutor) {
26+ mockSysExec.EXPECT().RunSimple("mountpoint", "-q", config.PathMnt).Return(system.CommandResult{ExitCode: 0}).Times(1)
27+ mockSysExec.EXPECT().RunSimple("mountpoint", "-q", config.PathMntBoot).Return(system.CommandResult{ExitCode: 0}).Times(1)
28+ },
29+ wantErr: false,
30+ },
31+ {
32+ name: "/mnt not mounted",
33+ setupMocks: func(mockSysExec *mocks.MockSystemExecutor) {
34+ mockSysExec.EXPECT().RunSimple("mountpoint", "-q", config.PathMnt).Return(system.CommandResult{ExitCode: 32}).Times(1)
35+ },
36+ wantErr: true,
37+ errContains: "is not mounted",
38+ },
39+ {
40+ name: "/mnt/boot not mounted",
41+ setupMocks: func(mockSysExec *mocks.MockSystemExecutor) {
42+ mockSysExec.EXPECT().RunSimple("mountpoint", "-q", config.PathMnt).Return(system.CommandResult{ExitCode: 0}).Times(1)
43+ mockSysExec.EXPECT().RunSimple("mountpoint", "-q", config.PathMntBoot).Return(system.CommandResult{ExitCode: 32}).Times(1)
44+ },
45+ wantErr: true,
46+ errContains: "is not mounted",
47+ },
48+ }
49+
50+ for _, tt := range tests {
51+ t.Run(tt.name, func(t *testing.T) {
52+ ctrl := gomock.NewController(t)
53+ defer ctrl.Finish()
54+
55+ mockFS := mocks.NewMockFileSystem(ctrl)
56+ mockSysExec := mocks.NewMockSystemExecutor(ctrl)
57+ mockChrExec := mocks.NewMockChrootExecutor(ctrl)
58+
59+ tt.setupMocks(mockSysExec)
60+
61+ tmpDir := t.TempDir()
62+ logPath := filepath.Join(tmpDir, "test.log")
63+ log, err := logger.New(logPath, false)
64+ if err != nil {
65+ t.Fatalf("Failed to create logger: %v", err)
66+ }
67+ defer log.Close()
68+
69+ cfg := config.NewConfig("test")
70+ phase := NewBootPhase(cfg, log, mockFS, mockSysExec, mockChrExec)
71+
72+ err = phase.PreCheck()
73+ if (err != nil) != tt.wantErr {
74+ t.Errorf("PreCheck() error = %v, wantErr %v", err, tt.wantErr)
75+ return
76+ }
77+ if tt.wantErr && err != nil {
78+ if errMsg := err.Error(); tt.errContains != "" && !containsSubstring(errMsg, tt.errContains) {
79+ t.Errorf("PreCheck() error = %q, should contain %q", errMsg, tt.errContains)
80+ }
81+ }
82+ })
83+ }
84+}
85+
86+// TestBootPhaseExtractPartitionNumber tests partition number extraction
87+func TestBootPhaseExtractPartitionNumber(t *testing.T) {
88+ tests := []struct {
89+ name string
90+ devicePath string
91+ expectedNum string
92+ }{
93+ {
94+ name: "SATA partition /dev/sda1",
95+ devicePath: "/dev/sda1",
96+ expectedNum: "1",
97+ },
98+ {
99+ name: "SATA partition /dev/sdb2",
100+ devicePath: "/dev/sdb2",
101+ expectedNum: "2",
102+ },
103+ {
104+ name: "NVMe partition /dev/nvme0n1p1",
105+ devicePath: "/dev/nvme0n1p1",
106+ expectedNum: "1",
107+ },
108+ {
109+ name: "NVMe partition /dev/nvme0n1p2",
110+ devicePath: "/dev/nvme0n1p2",
111+ expectedNum: "2",
112+ },
113+ {
114+ name: "VirtIO partition /dev/vda1",
115+ devicePath: "/dev/vda1",
116+ expectedNum: "1",
117+ },
118+ {
119+ name: "Multiple digit partition /dev/sda12",
120+ devicePath: "/dev/sda12",
121+ expectedNum: "12",
122+ },
123+ {
124+ name: "No partition number - returns empty",
125+ devicePath: "/dev/sda",
126+ expectedNum: "",
127+ },
128+ }
129+
130+ for _, tt := range tests {
131+ t.Run(tt.name, func(t *testing.T) {
132+ ctrl := gomock.NewController(t)
133+ defer ctrl.Finish()
134+
135+ mockFS := mocks.NewMockFileSystem(ctrl)
136+ mockSysExec := mocks.NewMockSystemExecutor(ctrl)
137+ mockChrExec := mocks.NewMockChrootExecutor(ctrl)
138+
139+ tmpDir := t.TempDir()
140+ logPath := filepath.Join(tmpDir, "test.log")
141+ log, err := logger.New(logPath, false)
142+ if err != nil {
143+ t.Fatalf("Failed to create logger: %v", err)
144+ }
145+ defer log.Close()
146+
147+ cfg := config.NewConfig("test")
148+ phase := NewBootPhase(cfg, log, mockFS, mockSysExec, mockChrExec)
149+
150+ result := phase.extractPartitionNumber(tt.devicePath)
151+ if result != tt.expectedNum {
152+ t.Errorf("extractPartitionNumber(%q) = %q, want %q", tt.devicePath, result, tt.expectedNum)
153+ }
154+ })
155+ }
156+}
157+
158+// TestBootPhasePostCheck tests boot configuration validation
159+func TestBootPhasePostCheck(t *testing.T) {
160+ tests := []struct {
161+ name string
162+ setupMocks func(*mocks.MockFileSystem)
163+ wantErr bool
164+ errContains string
165+ }{
166+ {
167+ name: "Both bootloader and config exist",
168+ setupMocks: func(mockFS *mocks.MockFileSystem) {
169+ bootloaderPath := filepath.Join(config.PathMntBootEFILimine, config.FileLimineBootloader)
170+ configPath := filepath.Join(config.PathMntBootEFILimine, config.FileLimineConfig)
171+
172+ mockFS.EXPECT().Stat(bootloaderPath).Return(mockFileInfo{}, nil).Times(1)
173+ mockFS.EXPECT().IsNotExist(gomock.Any()).Return(false).Times(1)
174+ mockFS.EXPECT().Stat(configPath).Return(mockFileInfo{}, nil).Times(1)
175+ mockFS.EXPECT().IsNotExist(gomock.Any()).Return(false).Times(1)
176+ },
177+ wantErr: false,
178+ },
179+ {
180+ name: "Bootloader missing",
181+ setupMocks: func(mockFS *mocks.MockFileSystem) {
182+ bootloaderPath := filepath.Join(config.PathMntBootEFILimine, config.FileLimineBootloader)
183+
184+ mockFS.EXPECT().Stat(bootloaderPath).Return(nil, fmt.Errorf("not found")).Times(1)
185+ mockFS.EXPECT().IsNotExist(gomock.Any()).Return(true).Times(1)
186+ },
187+ wantErr: true,
188+ errContains: "bootloader was not installed",
189+ },
190+ {
191+ name: "Config missing",
192+ setupMocks: func(mockFS *mocks.MockFileSystem) {
193+ bootloaderPath := filepath.Join(config.PathMntBootEFILimine, config.FileLimineBootloader)
194+ configPath := filepath.Join(config.PathMntBootEFILimine, config.FileLimineConfig)
195+
196+ mockFS.EXPECT().Stat(bootloaderPath).Return(mockFileInfo{}, nil).Times(1)
197+ mockFS.EXPECT().IsNotExist(gomock.Any()).Return(false).Times(1)
198+ mockFS.EXPECT().Stat(configPath).Return(nil, fmt.Errorf("not found")).Times(1)
199+ mockFS.EXPECT().IsNotExist(gomock.Any()).Return(true).Times(1)
200+ },
201+ wantErr: true,
202+ errContains: "config was not created",
203+ },
204+ }
205+
206+ for _, tt := range tests {
207+ t.Run(tt.name, func(t *testing.T) {
208+ ctrl := gomock.NewController(t)
209+ defer ctrl.Finish()
210+
211+ mockFS := mocks.NewMockFileSystem(ctrl)
212+ mockSysExec := mocks.NewMockSystemExecutor(ctrl)
213+ mockChrExec := mocks.NewMockChrootExecutor(ctrl)
214+
215+ tt.setupMocks(mockFS)
216+
217+ tmpDir := t.TempDir()
218+ logPath := filepath.Join(tmpDir, "test.log")
219+ log, err := logger.New(logPath, false)
220+ if err != nil {
221+ t.Fatalf("Failed to create logger: %v", err)
222+ }
223+ defer log.Close()
224+
225+ cfg := config.NewConfig("test")
226+ cfg.ConfigPath = filepath.Join(tmpDir, "config")
227+
228+ phase := NewBootPhase(cfg, log, mockFS, mockSysExec, mockChrExec)
229+
230+ err = phase.PostCheck()
231+ if (err != nil) != tt.wantErr {
232+ t.Errorf("PostCheck() error = %v, wantErr %v", err, tt.wantErr)
233+ return
234+ }
235+ if tt.wantErr && err != nil {
236+ if errMsg := err.Error(); tt.errContains != "" && !containsSubstring(errMsg, tt.errContains) {
237+ t.Errorf("PostCheck() error = %q, should contain %q", errMsg, tt.errContains)
238+ }
239+ }
240+ })
241+ }
242+}
243+
244+// TestBootPhaseRollback tests boot phase rollback
245+func TestBootPhaseRollback(t *testing.T) {
246+ tests := []struct {
247+ name string
248+ setupMocks func(*mocks.MockFileSystem)
249+ wantErr bool
250+ }{
251+ {
252+ name: "Rollback success",
253+ setupMocks: func(mockFS *mocks.MockFileSystem) {
254+ mockFS.EXPECT().RemoveAll(config.PathMntBootEFILimine).Return(nil).Times(1)
255+ },
256+ wantErr: false,
257+ },
258+ {
259+ name: "Rollback with directory removal failure - continues",
260+ setupMocks: func(mockFS *mocks.MockFileSystem) {
261+ mockFS.EXPECT().RemoveAll(config.PathMntBootEFILimine).Return(fmt.Errorf("permission denied")).Times(1)
262+ },
263+ wantErr: false, // Rollback doesn't fail on removal errors
264+ },
265+ }
266+
267+ for _, tt := range tests {
268+ t.Run(tt.name, func(t *testing.T) {
269+ ctrl := gomock.NewController(t)
270+ defer ctrl.Finish()
271+
272+ mockFS := mocks.NewMockFileSystem(ctrl)
273+ mockSysExec := mocks.NewMockSystemExecutor(ctrl)
274+ mockChrExec := mocks.NewMockChrootExecutor(ctrl)
275+
276+ tt.setupMocks(mockFS)
277+
278+ tmpDir := t.TempDir()
279+ logPath := filepath.Join(tmpDir, "test.log")
280+ log, err := logger.New(logPath, false)
281+ if err != nil {
282+ t.Fatalf("Failed to create logger: %v", err)
283+ }
284+ defer log.Close()
285+
286+ cfg := config.NewConfig("test")
287+ phase := NewBootPhase(cfg, log, mockFS, mockSysExec, mockChrExec)
288+
289+ err = phase.Rollback()
290+ if (err != nil) != tt.wantErr {
291+ t.Errorf("Rollback() error = %v, wantErr %v", err, tt.wantErr)
292+ }
293+ })
294+ }
295+}
296+
297+// TestBootPhaseCanSkip tests the CanSkip method
298+func TestBootPhaseCanSkip(t *testing.T) {
299+ ctrl := gomock.NewController(t)
300+ defer ctrl.Finish()
301+
302+ mockFS := mocks.NewMockFileSystem(ctrl)
303+ mockSysExec := mocks.NewMockSystemExecutor(ctrl)
304+ mockChrExec := mocks.NewMockChrootExecutor(ctrl)
305+
306+ tmpDir := t.TempDir()
307+ logPath := filepath.Join(tmpDir, "test.log")
308+ log, err := logger.New(logPath, false)
309+ if err != nil {
310+ t.Fatalf("Failed to create logger: %v", err)
311+ }
312+ defer log.Close()
313+
314+ cfg := config.NewConfig("test")
315+ phase := NewBootPhase(cfg, log, mockFS, mockSysExec, mockChrExec)
316+
317+ if phase.CanSkip() {
318+ t.Error("CanSkip() = true, want false")
319+ }
320+}
321+
322+// TestBootPhaseExecute tests the Execute method structure
323+func TestBootPhaseExecute(t *testing.T) {
324+ ctrl := gomock.NewController(t)
325+ defer ctrl.Finish()
326+
327+ mockFS := mocks.NewMockFileSystem(ctrl)
328+ mockSysExec := mocks.NewMockSystemExecutor(ctrl)
329+ mockChrExec := mocks.NewMockChrootExecutor(ctrl)
330+
331+ // Allow any file system operations since Execute calls various methods
332+ mockFS.EXPECT().ReadFile(gomock.Any()).Return([]byte{}, nil).AnyTimes()
333+ mockFS.EXPECT().WriteFile(gomock.Any(), gomock.Any(), gomock.Any()).Return(nil).AnyTimes()
334+ mockFS.EXPECT().MkdirAll(gomock.Any(), gomock.Any()).Return(nil).AnyTimes()
335+ mockChrExec.EXPECT().ChrootExec(gomock.Any(), gomock.Any(), gomock.Any()).Return(nil).AnyTimes()
336+ mockSysExec.EXPECT().RunSimple(gomock.Any(), gomock.Any(), gomock.Any()).Return(system.CommandResult{Output: "1234-5678"}).AnyTimes()
337+
338+ tmpDir := t.TempDir()
339+ logPath := filepath.Join(tmpDir, "test.log")
340+ log, err := logger.New(logPath, false)
341+ if err != nil {
342+ t.Fatalf("Failed to create logger: %v", err)
343+ }
344+ defer log.Close()
345+
346+ cfg := config.NewConfig("test")
347+ cfg.TargetDisk = "/dev/sda"
348+ cfg.EFIPartition = "/dev/sda1"
349+ cfg.RootPartition = "/dev/sda2"
350+ cfg.KernelChoice = "linux"
351+
352+ phase := NewBootPhase(cfg, log, mockFS, mockSysExec, mockChrExec)
353+
354+ progressChan := make(chan ProgressUpdate, 100)
355+
356+ // Note: Full Execute testing is complex due to logger.ExecCommand() and various file system calls
357+ // We just verify that Execute returns a PhaseResult
358+ result := phase.Execute(progressChan)
359+
360+ // Verify result type is correct - either success or error, the structure is valid
361+ _ = result // If we got here, Execute returned a valid result structure
362+}
363+
364+// TestBootPhaseKernelParameterConstruction tests kernel parameter building
365+func TestBootPhaseKernelParameterConstruction(t *testing.T) {
366+ tests := []struct {
367+ name string
368+ encryptionType string
369+ rootUUID string
370+ expectedParams string // partial match check
371+ }{
372+ {
373+ name: "Unencrypted root",
374+ encryptionType: config.EncryptionNone,
375+ rootUUID: "1234-5678",
376+ expectedParams: "root=UUID=1234-5678",
377+ },
378+ {
379+ name: "LUKS encrypted root",
380+ encryptionType: config.EncryptionLUKS,
381+ rootUUID: "1234-5678",
382+ expectedParams: "cryptdevice=UUID=1234-5678:cryptroot",
383+ },
384+ {
385+ name: "LUKS+LVM encrypted root",
386+ encryptionType: config.EncryptionLUKSLVM,
387+ rootUUID: "1234-5678",
388+ expectedParams: "cryptdevice=UUID=1234-5678:cryptroot",
389+ },
390+ }
391+
392+ for _, tt := range tests {
393+ t.Run(tt.name, func(t *testing.T) {
394+ ctrl := gomock.NewController(t)
395+ defer ctrl.Finish()
396+
397+ mockFS := mocks.NewMockFileSystem(ctrl)
398+ mockSysExec := mocks.NewMockSystemExecutor(ctrl)
399+ mockChrExec := mocks.NewMockChrootExecutor(ctrl)
400+
401+ tmpDir := t.TempDir()
402+ logPath := filepath.Join(tmpDir, "test.log")
403+ log, err := logger.New(logPath, false)
404+ if err != nil {
405+ t.Fatalf("Failed to create logger: %v", err)
406+ }
407+ defer log.Close()
408+
409+ cfg := config.NewConfig("test")
410+ cfg.EncryptionType = tt.encryptionType
411+ cfg.RootPartition = "/dev/sda2"
412+
413+ phase := NewBootPhase(cfg, log, mockFS, mockSysExec, mockChrExec)
414+
415+ // Verify encryption type is stored correctly
416+ if phase.config.EncryptionType != tt.encryptionType {
417+ t.Errorf("Encryption type = %q, want %q", phase.config.EncryptionType, tt.encryptionType)
418+ }
419+ })
420+ }
421+}
422+
423+// TestBootPhaseInstallPathConstruction tests install path building
424+func TestBootPhaseInstallPathConstruction(t *testing.T) {
425+ tests := []struct {
426+ name string
427+ filename string
428+ expected string
429+ }{
430+ {
431+ name: "Limine config template",
432+ filename: config.LimineConfigTemplate,
433+ expected: filepath.Join(config.DefaultInstallDir, config.LimineConfigTemplate),
434+ },
435+ {
436+ name: "Custom filename",
437+ filename: "test.conf",
438+ expected: filepath.Join(config.DefaultInstallDir, "test.conf"),
439+ },
440+ }
441+
442+ for _, tt := range tests {
443+ t.Run(tt.name, func(t *testing.T) {
444+ ctrl := gomock.NewController(t)
445+ defer ctrl.Finish()
446+
447+ mockFS := mocks.NewMockFileSystem(ctrl)
448+ mockSysExec := mocks.NewMockSystemExecutor(ctrl)
449+ mockChrExec := mocks.NewMockChrootExecutor(ctrl)
450+
451+ tmpDir := t.TempDir()
452+ logPath := filepath.Join(tmpDir, "test.log")
453+ log, err := logger.New(logPath, false)
454+ if err != nil {
455+ t.Fatalf("Failed to create logger: %v", err)
456+ }
457+ defer log.Close()
458+
459+ cfg := config.NewConfig("test")
460+ phase := NewBootPhase(cfg, log, mockFS, mockSysExec, mockChrExec)
461+
462+ result := phase.getInstallPath(tt.filename)
463+ if result != tt.expected {
464+ t.Errorf("getInstallPath(%q) = %q, want %q", tt.filename, result, tt.expected)
465+ }
466+ })
467+ }
468+}
469+
470+// TestBootPhaseFileSystemOperations tests file system interaction patterns
471+func TestBootPhaseFileSystemOperations(t *testing.T) {
472+ tests := []struct {
473+ name string
474+ operation string // "read", "write", "mkdir"
475+ }{
476+ {
477+ name: "Mkinitcpio config read",
478+ operation: "read",
479+ },
480+ {
481+ name: "Limine config write",
482+ operation: "write",
483+ },
484+ {
485+ name: "Create Limine directory",
486+ operation: "mkdir",
487+ },
488+ }
489+
490+ for _, tt := range tests {
491+ t.Run(tt.name, func(t *testing.T) {
492+ ctrl := gomock.NewController(t)
493+ defer ctrl.Finish()
494+
495+ mockFS := mocks.NewMockFileSystem(ctrl)
496+ mockSysExec := mocks.NewMockSystemExecutor(ctrl)
497+ mockChrExec := mocks.NewMockChrootExecutor(ctrl)
498+
499+ // Setup basic expectations based on operation
500+ switch tt.operation {
501+ case "read":
502+ mockFS.EXPECT().ReadFile(gomock.Any()).Return([]byte("content"), nil).AnyTimes()
503+ case "write":
504+ mockFS.EXPECT().WriteFile(gomock.Any(), gomock.Any(), gomock.Any()).Return(nil).AnyTimes()
505+ case "mkdir":
506+ mockFS.EXPECT().MkdirAll(gomock.Any(), gomock.Any()).Return(nil).AnyTimes()
507+ }
508+
509+ tmpDir := t.TempDir()
510+ logPath := filepath.Join(tmpDir, "test.log")
511+ log, err := logger.New(logPath, false)
512+ if err != nil {
513+ t.Fatalf("Failed to create logger: %v", err)
514+ }
515+ defer log.Close()
516+
517+ cfg := config.NewConfig("test")
518+ phase := NewBootPhase(cfg, log, mockFS, mockSysExec, mockChrExec)
519+
520+ // Verify phase can be created
521+ if phase == nil {
522+ t.Error("BootPhase is nil")
523+ }
524+ })
525+ }
526+}
M internal/phases/bootstrap.go +13 -9
@@ -4,29 +4,33 @@ import (
44 "fmt"
55 "io"
66 "net/http"
7- "os"
87 "path/filepath"
98
109 "github.com/bnema/archup/internal/config"
10+ "github.com/bnema/archup/internal/interfaces"
1111 "github.com/bnema/archup/internal/logger"
1212 )
1313
1414 // BootstrapPhase handles initial setup and file downloads
1515 type BootstrapPhase struct {
1616 *BasePhase
17+ httpClient interfaces.HTTPClient
18+ fs interfaces.FileSystem
1719 }
1820
1921 // NewBootstrapPhase creates a new bootstrap phase
20-func NewBootstrapPhase(cfg *config.Config, log *logger.Logger) *BootstrapPhase {
22+func NewBootstrapPhase(cfg *config.Config, log *logger.Logger, httpClient interfaces.HTTPClient, fs interfaces.FileSystem) *BootstrapPhase {
2123 return &BootstrapPhase{
22- BasePhase: NewBasePhase("bootstrap", "Bootstrap", cfg, log),
24+ BasePhase: NewBasePhase("bootstrap", "Bootstrap", cfg, log),
25+ httpClient: httpClient,
26+ fs: fs,
2327 }
2428 }
2529
2630 // PreCheck validates bootstrap prerequisites
2731 func (p *BootstrapPhase) PreCheck() error {
2832 // Check internet connectivity
29- resp, err := http.Get("https://raw.githubusercontent.com")
33+ resp, err := p.httpClient.Get("https://raw.githubusercontent.com")
3034 switch {
3135 case err != nil:
3236 return fmt.Errorf("no internet connectivity: %w", err)
@@ -41,7 +45,7 @@ func (p *BootstrapPhase) Execute(progressChan chan<- ProgressUpdate) PhaseResult
4145 p.SendOutput(progressChan, "Starting bootstrap phase...")
4246
4347 // Create install directory
44- switch err := os.MkdirAll(config.DefaultInstallDir, 0755); {
48+ switch err := p.fs.MkdirAll(config.DefaultInstallDir, 0755); {
4549 case err != nil:
4650 p.SendError(progressChan, err)
4751 return PhaseResult{Success: false, Error: err}
@@ -126,13 +130,13 @@ func (p *BootstrapPhase) Execute(progressChan chan<- ProgressUpdate) PhaseResult
126130 func (p *BootstrapPhase) downloadFile(url, destPath string) error {
127131 // Create destination directory if needed
128132 destDir := filepath.Dir(destPath)
129- switch err := os.MkdirAll(destDir, 0755); {
133+ switch err := p.fs.MkdirAll(destDir, 0755); {
130134 case err != nil:
131135 return fmt.Errorf("failed to create directory: %w", err)
132136 }
133137
134138 // Download file
135- resp, err := http.Get(url)
139+ resp, err := p.httpClient.Get(url)
136140 switch {
137141 case err != nil:
138142 return fmt.Errorf("HTTP request failed: %w", err)
@@ -145,7 +149,7 @@ func (p *BootstrapPhase) downloadFile(url, destPath string) error {
145149 }
146150
147151 // Create destination file
148- destFile, err := os.Create(destPath)
152+ destFile, err := p.fs.Create(destPath)
149153 switch {
150154 case err != nil:
151155 return fmt.Errorf("failed to create file: %w", err)
@@ -164,7 +168,7 @@ func (p *BootstrapPhase) downloadFile(url, destPath string) error {
164168 // Rollback removes downloaded files
165169 func (p *BootstrapPhase) Rollback() error {
166170 // Remove install directory
167- switch err := os.RemoveAll(config.DefaultInstallDir); {
171+ switch err := p.fs.RemoveAll(config.DefaultInstallDir); {
168172 case err != nil:
169173 return fmt.Errorf("failed to remove install directory: %w", err)
170174 }
M internal/phases/bootstrap_test.go +1 -9
@@ -346,12 +346,4 @@ func (m *MockWriteCloser) Close() error {
346346 return nil
347347 }
348348
349-// containsSubstring helper function (also defined in preflight_test.go)
350-func containsSubstring(s, substr string) bool {
351- for i := 0; i < len(s)-len(substr)+1; i++ {
352- if s[i:i+len(substr)] == substr {
353- return true
354- }
355- }
356- return false
357-}
349+// containsSubstring is defined in helpers_test.go to avoid duplication
A internal/phases/mocks/mock_phase.go +381 -0
@@ -0,0 +1,381 @@
1+// Code generated by MockGen. DO NOT EDIT.
2+// Source: internal/phases/phase.go
3+//
4+// Generated by this command:
5+//
6+// mockgen -source=internal/phases/phase.go -destination=internal/phases/mocks/mock_phase.go -package=mocks -typed -self_package=github.com/bnema/archup/internal/phases
7+//
8+
9+// Package mocks is a generated GoMock package.
10+package mocks
11+
12+import (
13+ reflect "reflect"
14+
15+ phases "github.com/bnema/archup/internal/phases"
16+ gomock "go.uber.org/mock/gomock"
17+)
18+
19+// MockPhase is a mock of Phase interface.
20+type MockPhase struct {
21+ ctrl *gomock.Controller
22+ recorder *MockPhaseMockRecorder
23+ isgomock struct{}
24+}
25+
26+// MockPhaseMockRecorder is the mock recorder for MockPhase.
27+type MockPhaseMockRecorder struct {
28+ mock *MockPhase
29+}
30+
31+// NewMockPhase creates a new mock instance.
32+func NewMockPhase(ctrl *gomock.Controller) *MockPhase {
33+ mock := &MockPhase{ctrl: ctrl}
34+ mock.recorder = &MockPhaseMockRecorder{mock}
35+ return mock
36+}
37+
38+// EXPECT returns an object that allows the caller to indicate expected use.
39+func (m *MockPhase) EXPECT() *MockPhaseMockRecorder {
40+ return m.recorder
41+}
42+
43+// CanSkip mocks base method.
44+func (m *MockPhase) CanSkip() bool {
45+ m.ctrl.T.Helper()
46+ ret := m.ctrl.Call(m, "CanSkip")
47+ ret0, _ := ret[0].(bool)
48+ return ret0
49+}
50+
51+// CanSkip indicates an expected call of CanSkip.
52+func (mr *MockPhaseMockRecorder) CanSkip() *MockPhaseCanSkipCall {
53+ mr.mock.ctrl.T.Helper()
54+ call := mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "CanSkip", reflect.TypeOf((*MockPhase)(nil).CanSkip))
55+ return &MockPhaseCanSkipCall{Call: call}
56+}
57+
58+// MockPhaseCanSkipCall wrap *gomock.Call
59+type MockPhaseCanSkipCall struct {
60+ *gomock.Call
61+}
62+
63+// Return rewrite *gomock.Call.Return
64+func (c *MockPhaseCanSkipCall) Return(arg0 bool) *MockPhaseCanSkipCall {
65+ c.Call = c.Call.Return(arg0)
66+ return c
67+}
68+
69+// Do rewrite *gomock.Call.Do
70+func (c *MockPhaseCanSkipCall) Do(f func() bool) *MockPhaseCanSkipCall {
71+ c.Call = c.Call.Do(f)
72+ return c
73+}
74+
75+// DoAndReturn rewrite *gomock.Call.DoAndReturn
76+func (c *MockPhaseCanSkipCall) DoAndReturn(f func() bool) *MockPhaseCanSkipCall {
77+ c.Call = c.Call.DoAndReturn(f)
78+ return c
79+}
80+
81+// Description mocks base method.
82+func (m *MockPhase) Description() string {
83+ m.ctrl.T.Helper()
84+ ret := m.ctrl.Call(m, "Description")
85+ ret0, _ := ret[0].(string)
86+ return ret0
87+}
88+
89+// Description indicates an expected call of Description.
90+func (mr *MockPhaseMockRecorder) Description() *MockPhaseDescriptionCall {
91+ mr.mock.ctrl.T.Helper()
92+ call := mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Description", reflect.TypeOf((*MockPhase)(nil).Description))
93+ return &MockPhaseDescriptionCall{Call: call}
94+}
95+
96+// MockPhaseDescriptionCall wrap *gomock.Call
97+type MockPhaseDescriptionCall struct {
98+ *gomock.Call
99+}
100+
101+// Return rewrite *gomock.Call.Return
102+func (c *MockPhaseDescriptionCall) Return(arg0 string) *MockPhaseDescriptionCall {
103+ c.Call = c.Call.Return(arg0)
104+ return c
105+}
106+
107+// Do rewrite *gomock.Call.Do
108+func (c *MockPhaseDescriptionCall) Do(f func() string) *MockPhaseDescriptionCall {
109+ c.Call = c.Call.Do(f)
110+ return c
111+}
112+
113+// DoAndReturn rewrite *gomock.Call.DoAndReturn
114+func (c *MockPhaseDescriptionCall) DoAndReturn(f func() string) *MockPhaseDescriptionCall {
115+ c.Call = c.Call.DoAndReturn(f)
116+ return c
117+}
118+
119+// Execute mocks base method.
120+func (m *MockPhase) Execute(progressChan chan<- phases.ProgressUpdate) phases.PhaseResult {
121+ m.ctrl.T.Helper()
122+ ret := m.ctrl.Call(m, "Execute", progressChan)
123+ ret0, _ := ret[0].(phases.PhaseResult)
124+ return ret0
125+}
126+
127+// Execute indicates an expected call of Execute.
128+func (mr *MockPhaseMockRecorder) Execute(progressChan any) *MockPhaseExecuteCall {
129+ mr.mock.ctrl.T.Helper()
130+ call := mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Execute", reflect.TypeOf((*MockPhase)(nil).Execute), progressChan)
131+ return &MockPhaseExecuteCall{Call: call}
132+}
133+
134+// MockPhaseExecuteCall wrap *gomock.Call
135+type MockPhaseExecuteCall struct {
136+ *gomock.Call
137+}
138+
139+// Return rewrite *gomock.Call.Return
140+func (c *MockPhaseExecuteCall) Return(arg0 phases.PhaseResult) *MockPhaseExecuteCall {
141+ c.Call = c.Call.Return(arg0)
142+ return c
143+}
144+
145+// Do rewrite *gomock.Call.Do
146+func (c *MockPhaseExecuteCall) Do(f func(chan<- phases.ProgressUpdate) phases.PhaseResult) *MockPhaseExecuteCall {
147+ c.Call = c.Call.Do(f)
148+ return c
149+}
150+
151+// DoAndReturn rewrite *gomock.Call.DoAndReturn
152+func (c *MockPhaseExecuteCall) DoAndReturn(f func(chan<- phases.ProgressUpdate) phases.PhaseResult) *MockPhaseExecuteCall {
153+ c.Call = c.Call.DoAndReturn(f)
154+ return c
155+}
156+
157+// Name mocks base method.
158+func (m *MockPhase) Name() string {
159+ m.ctrl.T.Helper()
160+ ret := m.ctrl.Call(m, "Name")
161+ ret0, _ := ret[0].(string)
162+ return ret0
163+}
164+
165+// Name indicates an expected call of Name.
166+func (mr *MockPhaseMockRecorder) Name() *MockPhaseNameCall {
167+ mr.mock.ctrl.T.Helper()
168+ call := mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Name", reflect.TypeOf((*MockPhase)(nil).Name))
169+ return &MockPhaseNameCall{Call: call}
170+}
171+
172+// MockPhaseNameCall wrap *gomock.Call
173+type MockPhaseNameCall struct {
174+ *gomock.Call
175+}
176+
177+// Return rewrite *gomock.Call.Return
178+func (c *MockPhaseNameCall) Return(arg0 string) *MockPhaseNameCall {
179+ c.Call = c.Call.Return(arg0)
180+ return c
181+}
182+
183+// Do rewrite *gomock.Call.Do
184+func (c *MockPhaseNameCall) Do(f func() string) *MockPhaseNameCall {
185+ c.Call = c.Call.Do(f)
186+ return c
187+}
188+
189+// DoAndReturn rewrite *gomock.Call.DoAndReturn
190+func (c *MockPhaseNameCall) DoAndReturn(f func() string) *MockPhaseNameCall {
191+ c.Call = c.Call.DoAndReturn(f)
192+ return c
193+}
194+
195+// PostCheck mocks base method.
196+func (m *MockPhase) PostCheck() error {
197+ m.ctrl.T.Helper()
198+ ret := m.ctrl.Call(m, "PostCheck")
199+ ret0, _ := ret[0].(error)
200+ return ret0
201+}
202+
203+// PostCheck indicates an expected call of PostCheck.
204+func (mr *MockPhaseMockRecorder) PostCheck() *MockPhasePostCheckCall {
205+ mr.mock.ctrl.T.Helper()
206+ call := mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "PostCheck", reflect.TypeOf((*MockPhase)(nil).PostCheck))
207+ return &MockPhasePostCheckCall{Call: call}
208+}
209+
210+// MockPhasePostCheckCall wrap *gomock.Call
211+type MockPhasePostCheckCall struct {
212+ *gomock.Call
213+}
214+
215+// Return rewrite *gomock.Call.Return
216+func (c *MockPhasePostCheckCall) Return(arg0 error) *MockPhasePostCheckCall {
217+ c.Call = c.Call.Return(arg0)
218+ return c
219+}
220+
221+// Do rewrite *gomock.Call.Do
222+func (c *MockPhasePostCheckCall) Do(f func() error) *MockPhasePostCheckCall {
223+ c.Call = c.Call.Do(f)
224+ return c
225+}
226+
227+// DoAndReturn rewrite *gomock.Call.DoAndReturn
228+func (c *MockPhasePostCheckCall) DoAndReturn(f func() error) *MockPhasePostCheckCall {
229+ c.Call = c.Call.DoAndReturn(f)
230+ return c
231+}
232+
233+// PreCheck mocks base method.
234+func (m *MockPhase) PreCheck() error {
235+ m.ctrl.T.Helper()
236+ ret := m.ctrl.Call(m, "PreCheck")
237+ ret0, _ := ret[0].(error)
238+ return ret0
239+}
240+
241+// PreCheck indicates an expected call of PreCheck.
242+func (mr *MockPhaseMockRecorder) PreCheck() *MockPhasePreCheckCall {
243+ mr.mock.ctrl.T.Helper()
244+ call := mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "PreCheck", reflect.TypeOf((*MockPhase)(nil).PreCheck))
245+ return &MockPhasePreCheckCall{Call: call}
246+}
247+
248+// MockPhasePreCheckCall wrap *gomock.Call
249+type MockPhasePreCheckCall struct {
250+ *gomock.Call
251+}
252+
253+// Return rewrite *gomock.Call.Return
254+func (c *MockPhasePreCheckCall) Return(arg0 error) *MockPhasePreCheckCall {
255+ c.Call = c.Call.Return(arg0)
256+ return c
257+}
258+
259+// Do rewrite *gomock.Call.Do
260+func (c *MockPhasePreCheckCall) Do(f func() error) *MockPhasePreCheckCall {
261+ c.Call = c.Call.Do(f)
262+ return c
263+}
264+
265+// DoAndReturn rewrite *gomock.Call.DoAndReturn
266+func (c *MockPhasePreCheckCall) DoAndReturn(f func() error) *MockPhasePreCheckCall {
267+ c.Call = c.Call.DoAndReturn(f)
268+ return c
269+}
270+
271+// Rollback mocks base method.
272+func (m *MockPhase) Rollback() error {
273+ m.ctrl.T.Helper()
274+ ret := m.ctrl.Call(m, "Rollback")
275+ ret0, _ := ret[0].(error)
276+ return ret0
277+}
278+
279+// Rollback indicates an expected call of Rollback.
280+func (mr *MockPhaseMockRecorder) Rollback() *MockPhaseRollbackCall {
281+ mr.mock.ctrl.T.Helper()
282+ call := mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Rollback", reflect.TypeOf((*MockPhase)(nil).Rollback))
283+ return &MockPhaseRollbackCall{Call: call}
284+}
285+
286+// MockPhaseRollbackCall wrap *gomock.Call
287+type MockPhaseRollbackCall struct {
288+ *gomock.Call
289+}
290+
291+// Return rewrite *gomock.Call.Return
292+func (c *MockPhaseRollbackCall) Return(arg0 error) *MockPhaseRollbackCall {
293+ c.Call = c.Call.Return(arg0)
294+ return c
295+}
296+
297+// Do rewrite *gomock.Call.Do
298+func (c *MockPhaseRollbackCall) Do(f func() error) *MockPhaseRollbackCall {
299+ c.Call = c.Call.Do(f)
300+ return c
301+}
302+
303+// DoAndReturn rewrite *gomock.Call.DoAndReturn
304+func (c *MockPhaseRollbackCall) DoAndReturn(f func() error) *MockPhaseRollbackCall {
305+ c.Call = c.Call.DoAndReturn(f)
306+ return c
307+}
308+
309+// SetStatus mocks base method.
310+func (m *MockPhase) SetStatus(status phases.PhaseStatus) {
311+ m.ctrl.T.Helper()
312+ m.ctrl.Call(m, "SetStatus", status)
313+}
314+
315+// SetStatus indicates an expected call of SetStatus.
316+func (mr *MockPhaseMockRecorder) SetStatus(status any) *MockPhaseSetStatusCall {
317+ mr.mock.ctrl.T.Helper()
318+ call := mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SetStatus", reflect.TypeOf((*MockPhase)(nil).SetStatus), status)
319+ return &MockPhaseSetStatusCall{Call: call}
320+}
321+
322+// MockPhaseSetStatusCall wrap *gomock.Call
323+type MockPhaseSetStatusCall struct {
324+ *gomock.Call
325+}
326+
327+// Return rewrite *gomock.Call.Return
328+func (c *MockPhaseSetStatusCall) Return() *MockPhaseSetStatusCall {
329+ c.Call = c.Call.Return()
330+ return c
331+}
332+
333+// Do rewrite *gomock.Call.Do
334+func (c *MockPhaseSetStatusCall) Do(f func(phases.PhaseStatus)) *MockPhaseSetStatusCall {
335+ c.Call = c.Call.Do(f)
336+ return c
337+}
338+
339+// DoAndReturn rewrite *gomock.Call.DoAndReturn
340+func (c *MockPhaseSetStatusCall) DoAndReturn(f func(phases.PhaseStatus)) *MockPhaseSetStatusCall {
341+ c.Call = c.Call.DoAndReturn(f)
342+ return c
343+}
344+
345+// Status mocks base method.
346+func (m *MockPhase) Status() phases.PhaseStatus {
347+ m.ctrl.T.Helper()
348+ ret := m.ctrl.Call(m, "Status")
349+ ret0, _ := ret[0].(phases.PhaseStatus)
350+ return ret0
351+}
352+
353+// Status indicates an expected call of Status.
354+func (mr *MockPhaseMockRecorder) Status() *Mockphases.PhaseStatusCall {
355+ mr.mock.ctrl.T.Helper()
356+ call := mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Status", reflect.TypeOf((*MockPhase)(nil).Status))
357+ return &Mockphases.PhaseStatusCall{Call: call}
358+}
359+
360+// Mockphases.PhaseStatusCall wrap *gomock.Call
361+type Mockphases.PhaseStatusCall struct {
362+ *gomock.Call
363+}
364+
365+// Return rewrite *gomock.Call.Return
366+func (c *Mockphases.PhaseStatusCall) Return(arg0 phases.PhaseStatus) *Mockphases.PhaseStatusCall {
367+ c.Call = c.Call.Return(arg0)
368+ return c
369+}
370+
371+// Do rewrite *gomock.Call.Do
372+func (c *Mockphases.PhaseStatusCall) Do(f func() phases.PhaseStatus) *Mockphases.PhaseStatusCall {
373+ c.Call = c.Call.Do(f)
374+ return c
375+}
376+
377+// DoAndReturn rewrite *gomock.Call.DoAndReturn
378+func (c *Mockphases.PhaseStatusCall) DoAndReturn(f func() phases.PhaseStatus) *Mockphases.PhaseStatusCall {
379+ c.Call = c.Call.DoAndReturn(f)
380+ return c
381+}
A internal/phases/orchestrator_test.go +545 -0
@@ -0,0 +1,545 @@
1+package phases
2+
3+import (
4+ "fmt"
5+ "testing"
6+
7+ "github.com/bnema/archup/internal/config"
8+)
9+
10+// testPhase is a simple Phase implementation for testing orchestrator
11+type testPhase struct {
12+ *BasePhase
13+ preCheckErr error
14+ executeErr error
15+ postCheckErr error
16+ rollbackErr error
17+ canSkip bool
18+}
19+
20+func newTestPhase(name string, cfg *config.Config) *testPhase {
21+ return &testPhase{
22+ BasePhase: NewBasePhase(name, "Test "+name, cfg, nil),
23+ canSkip: false,
24+ }
25+}
26+
27+func (p *testPhase) PreCheck() error {
28+ return p.preCheckErr
29+}
30+
31+func (p *testPhase) Execute(progressChan chan<- ProgressUpdate) PhaseResult {
32+ if p.executeErr != nil {
33+ return PhaseResult{
34+ Success: false,
35+ Error: p.executeErr,
36+ }
37+ }
38+ return PhaseResult{
39+ Success: true,
40+ Message: "completed",
41+ }
42+}
43+
44+func (p *testPhase) PostCheck() error {
45+ return p.postCheckErr
46+}
47+
48+func (p *testPhase) Rollback() error {
49+ return p.rollbackErr
50+}
51+
52+func (p *testPhase) CanSkip() bool {
53+ return p.canSkip
54+}
55+
56+// TestOrchestratorRegistration tests phase registration
57+func TestOrchestratorRegistration(t *testing.T) {
58+ cfg := config.NewConfig("test")
59+ orch := NewOrchestrator(cfg, "test.log")
60+
61+ t.Run("register valid phase", func(t *testing.T) {
62+ phase := newTestPhase("test-phase", cfg)
63+ err := orch.RegisterPhase(phase)
64+ if err != nil {
65+ t.Errorf("Failed to register valid phase: %v", err)
66+ }
67+
68+ phases := orch.Phases()
69+ if len(phases) != 1 {
70+ t.Errorf("Expected 1 phase, got %d", len(phases))
71+ }
72+ })
73+
74+ t.Run("register nil phase", func(t *testing.T) {
75+ orch2 := NewOrchestrator(cfg, "test.log")
76+ err := orch2.RegisterPhase(nil)
77+ if err == nil {
78+ t.Error("Expected error for nil phase, got nil")
79+ }
80+ })
81+
82+ t.Run("register duplicate phase name", func(t *testing.T) {
83+ orch2 := NewOrchestrator(cfg, "test.log")
84+ phase1 := newTestPhase("duplicate", cfg)
85+ phase2 := newTestPhase("duplicate", cfg)
86+
87+ orch2.RegisterPhase(phase1)
88+ err := orch2.RegisterPhase(phase2)
89+ if err == nil {
90+ t.Error("Expected error for duplicate phase name, got nil")
91+ }
92+ })
93+}
94+
95+// TestOrchestratorCurrentPhase tests CurrentPhase navigation
96+func TestOrchestratorCurrentPhase(t *testing.T) {
97+ cfg := config.NewConfig("test")
98+ orch := NewOrchestrator(cfg, "test.log")
99+
100+ phase := newTestPhase("test", cfg)
101+ orch.RegisterPhase(phase)
102+
103+ // Before execution, current should be nil
104+ if orch.CurrentPhase() != nil {
105+ t.Error("CurrentPhase should be nil before execution")
106+ }
107+
108+ // After executing, current should be set
109+ orch.ExecutePhase(phase)
110+ current := orch.CurrentPhase()
111+ if current == nil {
112+ t.Error("CurrentPhase should not be nil after execution")
113+ }
114+ if current.Name() != "test" {
115+ t.Errorf("CurrentPhase name = %q, want %q", current.Name(), "test")
116+ }
117+}
118+
119+// TestOrchestratorNextPhase tests NextPhase logic
120+func TestOrchestratorNextPhase(t *testing.T) {
121+ cfg := config.NewConfig("test")
122+ orch := NewOrchestrator(cfg, "test.log")
123+
124+ phase1 := newTestPhase("phase-1", cfg)
125+ phase2 := newTestPhase("phase-2", cfg)
126+ phase3 := newTestPhase("phase-3", cfg)
127+
128+ orch.RegisterPhase(phase1)
129+ orch.RegisterPhase(phase2)
130+ orch.RegisterPhase(phase3)
131+
132+ // Next should be phase-1
133+ next := orch.NextPhase()
134+ if next == nil || next.Name() != "phase-1" {
135+ t.Errorf("NextPhase = %v, want phase-1", next)
136+ }
137+
138+ // Mark phase-1 completed
139+ phase1.SetStatus(StatusCompleted)
140+
141+ // Next should be phase-2
142+ next = orch.NextPhase()
143+ if next == nil || next.Name() != "phase-2" {
144+ t.Errorf("NextPhase = %v, want phase-2", next)
145+ }
146+
147+ // Mark phase-2 skipped
148+ phase2.SetStatus(StatusSkipped)
149+
150+ // Next should be phase-3
151+ next = orch.NextPhase()
152+ if next == nil || next.Name() != "phase-3" {
153+ t.Errorf("NextPhase = %v, want phase-3", next)
154+ }
155+
156+ // Mark all done
157+ phase3.SetStatus(StatusCompleted)
158+
159+ // Next should be nil
160+ next = orch.NextPhase()
161+ if next != nil {
162+ t.Errorf("NextPhase = %v, want nil when all done", next)
163+ }
164+}
165+
166+// TestOrchestratorExecutePhaseSuccess tests successful phase execution
167+func TestOrchestratorExecutePhaseSuccess(t *testing.T) {
168+ cfg := config.NewConfig("test")
169+ orch := NewOrchestrator(cfg, "test.log")
170+
171+ phase := newTestPhase("test", cfg)
172+ orch.RegisterPhase(phase)
173+
174+ err := orch.ExecutePhase(phase)
175+ if err != nil {
176+ t.Errorf("ExecutePhase failed: %v", err)
177+ }
178+
179+ if phase.Status() != StatusCompleted {
180+ t.Errorf("Status = %v, want %v", phase.Status(), StatusCompleted)
181+ }
182+}
183+
184+// TestOrchestratorExecutePhasePreCheckFails tests PreCheck failure
185+func TestOrchestratorExecutePhasePreCheckFails(t *testing.T) {
186+ cfg := config.NewConfig("test")
187+ orch := NewOrchestrator(cfg, "test.log")
188+
189+ phase := newTestPhase("test", cfg)
190+ phase.preCheckErr = fmt.Errorf("precondition failed")
191+ orch.RegisterPhase(phase)
192+
193+ err := orch.ExecutePhase(phase)
194+ if err == nil {
195+ t.Error("Expected error from failed PreCheck")
196+ }
197+
198+ if phase.Status() != StatusFailed {
199+ t.Errorf("Status = %v, want %v", phase.Status(), StatusFailed)
200+ }
201+}
202+
203+// TestOrchestratorExecutePhaseExecuteFails tests Execute failure with Rollback
204+func TestOrchestratorExecutePhaseExecuteFails(t *testing.T) {
205+ cfg := config.NewConfig("test")
206+ orch := NewOrchestrator(cfg, "test.log")
207+
208+ phase := newTestPhase("test", cfg)
209+ phase.executeErr = fmt.Errorf("execution failed")
210+ orch.RegisterPhase(phase)
211+
212+ err := orch.ExecutePhase(phase)
213+ if err == nil {
214+ t.Error("Expected error from failed Execute")
215+ }
216+
217+ if phase.Status() != StatusFailed {
218+ t.Errorf("Status = %v, want %v", phase.Status(), StatusFailed)
219+ }
220+}
221+
222+// TestOrchestratorExecutePhaseRollbackFails tests Rollback failure
223+func TestOrchestratorExecutePhaseRollbackFails(t *testing.T) {
224+ cfg := config.NewConfig("test")
225+ orch := NewOrchestrator(cfg, "test.log")
226+
227+ phase := newTestPhase("test", cfg)
228+ phase.executeErr = fmt.Errorf("execution failed")
229+ phase.rollbackErr = fmt.Errorf("rollback also failed")
230+ orch.RegisterPhase(phase)
231+
232+ err := orch.ExecutePhase(phase)
233+ if err == nil {
234+ t.Error("Expected error from failed Rollback")
235+ }
236+
237+ if phase.Status() != StatusFailed {
238+ t.Errorf("Status = %v, want %v", phase.Status(), StatusFailed)
239+ }
240+}
241+
242+// TestOrchestratorExecutePhasePostCheckFails tests PostCheck failure
243+func TestOrchestratorExecutePhasePostCheckFails(t *testing.T) {
244+ cfg := config.NewConfig("test")
245+ orch := NewOrchestrator(cfg, "test.log")
246+
247+ phase := newTestPhase("test", cfg)
248+ phase.postCheckErr = fmt.Errorf("validation failed")
249+ orch.RegisterPhase(phase)
250+
251+ err := orch.ExecutePhase(phase)
252+ if err == nil {
253+ t.Error("Expected error from failed PostCheck")
254+ }
255+
256+ if phase.Status() != StatusFailed {
257+ t.Errorf("Status = %v, want %v", phase.Status(), StatusFailed)
258+ }
259+}
260+
261+// TestOrchestratorSkipPhase tests skipping phases
262+func TestOrchestratorSkipPhase(t *testing.T) {
263+ cfg := config.NewConfig("test")
264+ orch := NewOrchestrator(cfg, "test.log")
265+
266+ t.Run("skip skippable phase", func(t *testing.T) {
267+ phase := newTestPhase("skippable", cfg)
268+ phase.canSkip = true
269+ orch.RegisterPhase(phase)
270+
271+ err := orch.SkipPhase(phase)
272+ if err != nil {
273+ t.Errorf("SkipPhase failed: %v", err)
274+ }
275+
276+ if phase.Status() != StatusSkipped {
277+ t.Errorf("Status = %v, want %v", phase.Status(), StatusSkipped)
278+ }
279+ })
280+
281+ t.Run("skip non-skippable phase", func(t *testing.T) {
282+ orch2 := NewOrchestrator(cfg, "test.log")
283+ phase := newTestPhase("non-skippable", cfg)
284+ phase.canSkip = false
285+ orch2.RegisterPhase(phase)
286+
287+ err := orch2.SkipPhase(phase)
288+ if err == nil {
289+ t.Error("Expected error when skipping non-skippable phase")
290+ }
291+ })
292+}
293+
294+// TestOrchestratorProgress tests progress calculation
295+func TestOrchestratorProgress(t *testing.T) {
296+ cfg := config.NewConfig("test")
297+ orch := NewOrchestrator(cfg, "test.log")
298+
299+ phase1 := newTestPhase("phase-1", cfg)
300+ phase2 := newTestPhase("phase-2", cfg)
301+ phase3 := newTestPhase("phase-3", cfg)
302+
303+ orch.RegisterPhase(phase1)
304+ orch.RegisterPhase(phase2)
305+ orch.RegisterPhase(phase3)
306+
307+ // Initially all pending
308+ completed, total := orch.Progress()
309+ if total != 3 {
310+ t.Errorf("Total = %d, want 3", total)
311+ }
312+ if completed != 0 {
313+ t.Errorf("Completed = %d, want 0", completed)
314+ }
315+
316+ // Mark one completed, one skipped
317+ phase1.SetStatus(StatusCompleted)
318+ phase2.SetStatus(StatusSkipped)
319+
320+ completed, total = orch.Progress()
321+ if completed != 2 {
322+ t.Errorf("Completed = %d, want 2 (completed + skipped)", completed)
323+ }
324+}
325+
326+// TestOrchestratorIsComplete tests completion detection
327+func TestOrchestratorIsComplete(t *testing.T) {
328+ cfg := config.NewConfig("test")
329+
330+ t.Run("all completed", func(t *testing.T) {
331+ orch := NewOrchestrator(cfg, "test.log")
332+ phase := newTestPhase("test", cfg)
333+ phase.SetStatus(StatusCompleted)
334+ orch.RegisterPhase(phase)
335+
336+ if !orch.IsComplete() {
337+ t.Error("IsComplete should be true when all phases completed")
338+ }
339+ })
340+
341+ t.Run("with skipped", func(t *testing.T) {
342+ orch := NewOrchestrator(cfg, "test.log")
343+ phase := newTestPhase("test", cfg)
344+ phase.SetStatus(StatusSkipped)
345+ orch.RegisterPhase(phase)
346+
347+ if !orch.IsComplete() {
348+ t.Error("IsComplete should be true when all phases skipped")
349+ }
350+ })
351+
352+ t.Run("with pending", func(t *testing.T) {
353+ orch := NewOrchestrator(cfg, "test.log")
354+ phase := newTestPhase("test", cfg)
355+ orch.RegisterPhase(phase)
356+
357+ if orch.IsComplete() {
358+ t.Error("IsComplete should be false with pending phases")
359+ }
360+ })
361+}
362+
363+// TestOrchestratorHasFailed tests failure detection
364+func TestOrchestratorHasFailed(t *testing.T) {
365+ cfg := config.NewConfig("test")
366+
367+ t.Run("no failures", func(t *testing.T) {
368+ orch := NewOrchestrator(cfg, "test.log")
369+ phase := newTestPhase("test", cfg)
370+ phase.SetStatus(StatusCompleted)
371+ orch.RegisterPhase(phase)
372+
373+ if orch.HasFailed() {
374+ t.Error("HasFailed should be false when no phases failed")
375+ }
376+ })
377+
378+ t.Run("with failure", func(t *testing.T) {
379+ orch := NewOrchestrator(cfg, "test.log")
380+ phase := newTestPhase("test", cfg)
381+ phase.SetStatus(StatusFailed)
382+ orch.RegisterPhase(phase)
383+
384+ if !orch.HasFailed() {
385+ t.Error("HasFailed should be true when phase failed")
386+ }
387+ })
388+}
389+
390+// TestOrchestratorReset tests Reset functionality
391+func TestOrchestratorReset(t *testing.T) {
392+ cfg := config.NewConfig("test")
393+ orch := NewOrchestrator(cfg, "test.log")
394+
395+ phase1 := newTestPhase("phase-1", cfg)
396+ phase2 := newTestPhase("phase-2", cfg)
397+
398+ phase1.SetStatus(StatusCompleted)
399+ phase2.SetStatus(StatusFailed)
400+
401+ orch.RegisterPhase(phase1)
402+ orch.RegisterPhase(phase2)
403+
404+ // Execute to set currentIdx
405+ orch.ExecutePhase(phase1)
406+
407+ // Reset
408+ orch.Reset()
409+
410+ // Verify currentIdx reset
411+ if orch.CurrentPhase() != nil {
412+ t.Error("CurrentPhase should be nil after Reset")
413+ }
414+
415+ // Verify statuses reset
416+ if phase1.Status() != StatusPending {
417+ t.Errorf("phase1 Status = %v, want %v", phase1.Status(), StatusPending)
418+ }
419+ if phase2.Status() != StatusPending {
420+ t.Errorf("phase2 Status = %v, want %v", phase2.Status(), StatusPending)
421+ }
422+}
423+
424+// TestOrchestratorExecuteAll tests full sequence execution
425+func TestOrchestratorExecuteAll(t *testing.T) {
426+ cfg := config.NewConfig("test")
427+ orch := NewOrchestrator(cfg, "test.log")
428+
429+ phase1 := newTestPhase("phase-1", cfg)
430+ phase2 := newTestPhase("phase-2", cfg)
431+ phase3 := newTestPhase("phase-3", cfg)
432+
433+ orch.RegisterPhase(phase1)
434+ orch.RegisterPhase(phase2)
435+ orch.RegisterPhase(phase3)
436+
437+ err := orch.ExecuteAll()
438+ if err != nil {
439+ t.Errorf("ExecuteAll failed: %v", err)
440+ }
441+
442+ // Verify all completed
443+ if phase1.Status() != StatusCompleted {
444+ t.Errorf("phase1 Status = %v, want %v", phase1.Status(), StatusCompleted)
445+ }
446+ if phase2.Status() != StatusCompleted {
447+ t.Errorf("phase2 Status = %v, want %v", phase2.Status(), StatusCompleted)
448+ }
449+ if phase3.Status() != StatusCompleted {
450+ t.Errorf("phase3 Status = %v, want %v", phase3.Status(), StatusCompleted)
451+ }
452+}
453+
454+// TestOrchestratorExecuteAllStopsOnFailure tests that ExecuteAll stops on first failure
455+func TestOrchestratorExecuteAllStopsOnFailure(t *testing.T) {
456+ cfg := config.NewConfig("test")
457+ orch := NewOrchestrator(cfg, "test.log")
458+
459+ phase1 := newTestPhase("phase-1", cfg)
460+ phase2 := newTestPhase("phase-2", cfg)
461+ phase3 := newTestPhase("phase-3", cfg)
462+
463+ // Make phase-2 fail
464+ phase2.executeErr = fmt.Errorf("execution failed")
465+
466+ orch.RegisterPhase(phase1)
467+ orch.RegisterPhase(phase2)
468+ orch.RegisterPhase(phase3)
469+
470+ err := orch.ExecuteAll()
471+ if err == nil {
472+ t.Error("Expected error from ExecuteAll when phase-2 fails")
473+ }
474+
475+ // Verify phase-1 completed
476+ if phase1.Status() != StatusCompleted {
477+ t.Errorf("phase1 Status = %v, want %v", phase1.Status(), StatusCompleted)
478+ }
479+
480+ // Verify phase-2 failed
481+ if phase2.Status() != StatusFailed {
482+ t.Errorf("phase2 Status = %v, want %v", phase2.Status(), StatusFailed)
483+ }
484+
485+ // Verify phase-3 never executed
486+ if phase3.Status() != StatusPending {
487+ t.Errorf("phase3 Status = %v, want %v (should not execute)", phase3.Status(), StatusPending)
488+ }
489+}
490+
491+// TestOrchestratorExecuteNext tests ExecuteNext
492+func TestOrchestratorExecuteNext(t *testing.T) {
493+ cfg := config.NewConfig("test")
494+ orch := NewOrchestrator(cfg, "test.log")
495+
496+ phase := newTestPhase("test", cfg)
497+ orch.RegisterPhase(phase)
498+
499+ err := orch.ExecuteNext()
500+ if err != nil {
501+ t.Errorf("ExecuteNext failed: %v", err)
502+ }
503+
504+ if phase.Status() != StatusCompleted {
505+ t.Errorf("Status = %v, want %v", phase.Status(), StatusCompleted)
506+ }
507+}
508+
509+// TestOrchestratorExecuteNextNoPending tests ExecuteNext with no pending phases
510+func TestOrchestratorExecuteNextNoPending(t *testing.T) {
511+ cfg := config.NewConfig("test")
512+ orch := NewOrchestrator(cfg, "test.log")
513+
514+ phase := newTestPhase("test", cfg)
515+ phase.SetStatus(StatusCompleted)
516+ orch.RegisterPhase(phase)
517+
518+ err := orch.ExecuteNext()
519+ if err == nil {
520+ t.Error("Expected error when no pending phases")
521+ }
522+}
523+
524+// TestOrchestratorPhaseDurations tests duration tracking
525+func TestOrchestratorPhaseDurations(t *testing.T) {
526+ cfg := config.NewConfig("test")
527+ orch := NewOrchestrator(cfg, "test.log")
528+
529+ phase := newTestPhase("test", cfg)
530+ orch.RegisterPhase(phase)
531+
532+ err := orch.ExecutePhase(phase)
533+ if err != nil {
534+ t.Fatalf("ExecutePhase failed: %v", err)
535+ }
536+
537+ durations := orch.GetPhaseDurations()
538+ duration, exists := durations["test"]
539+ if !exists {
540+ t.Error("Expected duration for 'test' phase")
541+ }
542+ if duration == 0 {
543+ t.Error("Duration should be greater than 0")
544+ }
545+}
M internal/phases/partitioning.go +13 -11
@@ -5,19 +5,21 @@ import (
55 "strings"
66
77 "github.com/bnema/archup/internal/config"
8+ "github.com/bnema/archup/internal/interfaces"
89 "github.com/bnema/archup/internal/logger"
9- "github.com/bnema/archup/internal/system"
1010 )
1111
1212 // PartitioningPhase handles disk partitioning, formatting, and mounting
1313 type PartitioningPhase struct {
1414 *BasePhase
15+ sysExec interfaces.SystemExecutor
1516 }
1617
1718 // NewPartitioningPhase creates a new partitioning phase
18-func NewPartitioningPhase(cfg *config.Config, log *logger.Logger) *PartitioningPhase {
19+func NewPartitioningPhase(cfg *config.Config, log *logger.Logger, sysExec interfaces.SystemExecutor) *PartitioningPhase {
1920 return &PartitioningPhase{
2021 BasePhase: NewBasePhase("partitioning", "Disk Partitioning", cfg, log),
22+ sysExec: sysExec,
2123 }
2224 }
2325
@@ -186,7 +188,7 @@ func (p *PartitioningPhase) formatEncryptedRoot(progressChan chan<- ProgressUpda
186188
187189 // Note: In real implementation, pipe password securely via stdin
188190 // For now, documenting the approach
189- result = system.RunSimple("sh", "-c",
191+ result = p.sysExec.RunSimple("sh", "-c",
190192 fmt.Sprintf("printf '%%s' '%s' | cryptsetup luksFormat --type luks2 --batch-mode --pbkdf argon2id --iter-time 2000 --label ARCHUP_LUKS --key-file - %s",
191193 password, p.config.RootPartition))
192194
@@ -196,7 +198,7 @@ func (p *PartitioningPhase) formatEncryptedRoot(progressChan chan<- ProgressUpda
196198 }
197199
198200 // Open LUKS container
199- result = system.RunSimple("sh", "-c",
201+ result = p.sysExec.RunSimple("sh", "-c",
200202 fmt.Sprintf("printf '%%s' '%s' | cryptsetup open --key-file=- %s cryptroot",
201203 password, p.config.RootPartition))
202204
@@ -255,7 +257,7 @@ func (p *PartitioningPhase) createSubvolumes(progressChan chan<- ProgressUpdate)
255257 result = p.logger.ExecCommand("btrfs", "subvolume", "create", "/mnt/@")
256258 switch {
257259 case result.ExitCode != 0:
258- system.RunSimple("umount", "/mnt") // Cleanup
260+ p.sysExec.RunSimple("umount", "/mnt") // Cleanup
259261 return fmt.Errorf("@ subvolume creation failed: %w", result.Error)
260262 }
261263
@@ -263,7 +265,7 @@ func (p *PartitioningPhase) createSubvolumes(progressChan chan<- ProgressUpdate)
263265 result = p.logger.ExecCommand("btrfs", "subvolume", "create", "/mnt/@home")
264266 switch {
265267 case result.ExitCode != 0:
266- system.RunSimple("umount", "/mnt") // Cleanup
268+ p.sysExec.RunSimple("umount", "/mnt") // Cleanup
267269 return fmt.Errorf("@home subvolume creation failed: %w", result.Error)
268270 }
269271
@@ -334,7 +336,7 @@ func (p *PartitioningPhase) mountFilesystems(progressChan chan<- ProgressUpdate)
334336 // PostCheck validates mounts
335337 func (p *PartitioningPhase) PostCheck() error {
336338 // Verify /mnt is mounted
337- result := system.RunSimple("mountpoint", "-q", "/mnt")
339+ result := p.sysExec.RunSimple("mountpoint", "-q", "/mnt")
338340 switch {
339341 case result.ExitCode != 0:
340342 return fmt.Errorf("/mnt is not mounted")
@@ -346,16 +348,16 @@ func (p *PartitioningPhase) PostCheck() error {
346348 // Rollback unmounts filesystems
347349 func (p *PartitioningPhase) Rollback() error {
348350 // Unmount in reverse order
349- system.RunSimple("umount", "/mnt/boot")
350- system.RunSimple("umount", "/mnt/home")
351- system.RunSimple("umount", "/mnt")
351+ p.sysExec.RunSimple("umount", "/mnt/boot")
352+ p.sysExec.RunSimple("umount", "/mnt/home")
353+ p.sysExec.RunSimple("umount", "/mnt")
352354
353355 // Close LUKS if encrypted
354356 switch p.config.EncryptionType {
355357 case config.EncryptionLUKS, config.EncryptionLUKSLVM:
356358 switch {
357359 case p.config.CryptDevice != "":
358- system.RunSimple("cryptsetup", "close", "cryptroot")
360+ p.sysExec.RunSimple("cryptsetup", "close", "cryptroot")
359361 }
360362 }
361363
A internal/phases/partitioning_test.go +523 -0
@@ -0,0 +1,523 @@
1+package phases
2+
3+import (
4+ "fmt"
5+ "path/filepath"
6+ "reflect"
7+ "testing"
8+
9+ "github.com/bnema/archup/internal/config"
10+ "github.com/bnema/archup/internal/interfaces/mocks"
11+ "github.com/bnema/archup/internal/logger"
12+ "github.com/bnema/archup/internal/system"
13+ "go.uber.org/mock/gomock"
14+)
15+
16+// TestPartitioningPhasePreCheck tests partitioning prerequisites
17+func TestPartitioningPhasePreCheck(t *testing.T) {
18+ tests := []struct {
19+ name string
20+ targetDisk string
21+ wantErr bool
22+ errContains string
23+ }{
24+ {
25+ name: "Valid target disk",
26+ targetDisk: "/dev/sda",
27+ wantErr: false,
28+ errContains: "",
29+ },
30+ {
31+ name: "Missing target disk",
32+ targetDisk: "",
33+ wantErr: true,
34+ errContains: "target disk not selected",
35+ },
36+ {
37+ name: "NVMe target disk",
38+ targetDisk: "/dev/nvme0n1",
39+ wantErr: false,
40+ errContains: "",
41+ },
42+ }
43+
44+ for _, tt := range tests {
45+ t.Run(tt.name, func(t *testing.T) {
46+ ctrl := gomock.NewController(t)
47+ defer ctrl.Finish()
48+
49+ mockSysExec := mocks.NewMockSystemExecutor(ctrl)
50+
51+ tmpDir := t.TempDir()
52+ logPath := filepath.Join(tmpDir, "test.log")
53+ log, err := logger.New(logPath, false)
54+ if err != nil {
55+ t.Fatalf("Failed to create logger: %v", err)
56+ }
57+ defer log.Close()
58+
59+ cfg := config.NewConfig("test")
60+ cfg.TargetDisk = tt.targetDisk
61+
62+ phase := NewPartitioningPhase(cfg, log, mockSysExec)
63+
64+ err = phase.PreCheck()
65+ if (err != nil) != tt.wantErr {
66+ t.Errorf("PreCheck() error = %v, wantErr %v", err, tt.wantErr)
67+ return
68+ }
69+ if tt.wantErr && err != nil {
70+ if errMsg := err.Error(); tt.errContains != "" && !containsSubstring(errMsg, tt.errContains) {
71+ t.Errorf("PreCheck() error = %q, should contain %q", errMsg, tt.errContains)
72+ }
73+ }
74+ })
75+ }
76+}
77+
78+// TestPartitioningPhasePartitionNaming tests partition naming logic
79+func TestPartitioningPhasePartitionNaming(t *testing.T) {
80+ tests := []struct {
81+ name string
82+ targetDisk string
83+ expectedEFI string
84+ expectedRoot string
85+ }{
86+ {
87+ name: "NVMe disk naming",
88+ targetDisk: "/dev/nvme0n1",
89+ expectedEFI: "/dev/nvme0n1p1",
90+ expectedRoot: "/dev/nvme0n1p2",
91+ },
92+ {
93+ name: "SATA disk naming",
94+ targetDisk: "/dev/sda",
95+ expectedEFI: "/dev/sda1",
96+ expectedRoot: "/dev/sda2",
97+ },
98+ {
99+ name: "VirtIO disk naming",
100+ targetDisk: "/dev/vda",
101+ expectedEFI: "/dev/vda1",
102+ expectedRoot: "/dev/vda2",
103+ },
104+ }
105+
106+ for _, tt := range tests {
107+ t.Run(tt.name, func(t *testing.T) {
108+ ctrl := gomock.NewController(t)
109+ defer ctrl.Finish()
110+
111+ mockSysExec := mocks.NewMockSystemExecutor(ctrl)
112+
113+ tmpDir := t.TempDir()
114+ logPath := filepath.Join(tmpDir, "test.log")
115+ log, err := logger.New(logPath, false)
116+ if err != nil {
117+ t.Fatalf("Failed to create logger: %v", err)
118+ }
119+ defer log.Close()
120+
121+ cfg := config.NewConfig("test")
122+ cfg.TargetDisk = tt.targetDisk
123+
124+ phase := NewPartitioningPhase(cfg, log, mockSysExec)
125+
126+ // Verify partition names are set correctly in createPartitions logic
127+ // This is done by checking the naming convention
128+ phase.config.EFIPartition = tt.expectedEFI
129+ phase.config.RootPartition = tt.expectedRoot
130+
131+ if phase.config.EFIPartition != tt.expectedEFI {
132+ t.Errorf("EFI partition = %q, want %q", phase.config.EFIPartition, tt.expectedEFI)
133+ }
134+ if phase.config.RootPartition != tt.expectedRoot {
135+ t.Errorf("Root partition = %q, want %q", phase.config.RootPartition, tt.expectedRoot)
136+ }
137+ })
138+ }
139+}
140+
141+// TestPartitioningPhaseFormatRoot tests format method branching
142+func TestPartitioningPhaseFormatRoot(t *testing.T) {
143+ tests := []struct {
144+ name string
145+ encryptionType string
146+ expectedCallType string // "LUKS" or "Plain"
147+ }{
148+ {
149+ name: "Unencrypted root",
150+ encryptionType: config.EncryptionNone,
151+ expectedCallType: "Plain",
152+ },
153+ {
154+ name: "LUKS encrypted root",
155+ encryptionType: config.EncryptionLUKS,
156+ expectedCallType: "LUKS",
157+ },
158+ {
159+ name: "LUKS with LVM",
160+ encryptionType: config.EncryptionLUKSLVM,
161+ expectedCallType: "LUKS",
162+ },
163+ }
164+
165+ for _, tt := range tests {
166+ t.Run(tt.name, func(t *testing.T) {
167+ ctrl := gomock.NewController(t)
168+ defer ctrl.Finish()
169+
170+ mockSysExec := mocks.NewMockSystemExecutor(ctrl)
171+
172+ tmpDir := t.TempDir()
173+ logPath := filepath.Join(tmpDir, "test.log")
174+ log, err := logger.New(logPath, false)
175+ if err != nil {
176+ t.Fatalf("Failed to create logger: %v", err)
177+ }
178+ defer log.Close()
179+
180+ cfg := config.NewConfig("test")
181+ cfg.TargetDisk = "/dev/sda"
182+ cfg.EFIPartition = "/dev/sda1"
183+ cfg.RootPartition = "/dev/sda2"
184+ cfg.EncryptionType = tt.encryptionType
185+ cfg.UserPassword = "password123"
186+
187+ phase := NewPartitioningPhase(cfg, log, mockSysExec)
188+
189+ // Verify encryption type branching
190+ if phase.config.EncryptionType != tt.encryptionType {
191+ t.Errorf("Encryption type = %q, want %q", phase.config.EncryptionType, tt.encryptionType)
192+ }
193+ })
194+ }
195+}
196+
197+// TestPartitioningPhaseEncryptionValidation tests encryption password requirement
198+func TestPartitioningPhaseEncryptionValidation(t *testing.T) {
199+ tests := []struct {
200+ name string
201+ encryptionType string
202+ password string
203+ shouldRequire bool
204+ }{
205+ {
206+ name: "Unencrypted doesn't require password",
207+ encryptionType: config.EncryptionNone,
208+ password: "",
209+ shouldRequire: false,
210+ },
211+ {
212+ name: "LUKS requires password",
213+ encryptionType: config.EncryptionLUKS,
214+ password: "secure123",
215+ shouldRequire: true,
216+ },
217+ {
218+ name: "LUKS with empty password fails validation",
219+ encryptionType: config.EncryptionLUKS,
220+ password: "",
221+ shouldRequire: true, // Should have required one
222+ },
223+ }
224+
225+ for _, tt := range tests {
226+ t.Run(tt.name, func(t *testing.T) {
227+ ctrl := gomock.NewController(t)
228+ defer ctrl.Finish()
229+
230+ mockSysExec := mocks.NewMockSystemExecutor(ctrl)
231+
232+ tmpDir := t.TempDir()
233+ logPath := filepath.Join(tmpDir, "test.log")
234+ log, err := logger.New(logPath, false)
235+ if err != nil {
236+ t.Fatalf("Failed to create logger: %v", err)
237+ }
238+ defer log.Close()
239+
240+ cfg := config.NewConfig("test")
241+ cfg.TargetDisk = "/dev/sda"
242+ cfg.EFIPartition = "/dev/sda1"
243+ cfg.RootPartition = "/dev/sda2"
244+ cfg.EncryptionType = tt.encryptionType
245+ cfg.UserPassword = tt.password
246+
247+ phase := NewPartitioningPhase(cfg, log, mockSysExec)
248+
249+ // Validate password requirement
250+ if tt.shouldRequire && tt.encryptionType != config.EncryptionNone {
251+ if phase.config.UserPassword == "" && tt.password == "" {
252+ // Empty password for encryption - should fail in actual execution
253+ if phase.config.EncryptionType != config.EncryptionNone {
254+ // Password is required for encryption
255+ }
256+ }
257+ }
258+ })
259+ }
260+}
261+
262+// TestPartitioningPhasePostCheck tests mount point validation
263+func TestPartitioningPhasePostCheck(t *testing.T) {
264+ tests := []struct {
265+ name string
266+ setupMocks func(*mocks.MockSystemExecutor)
267+ wantErr bool
268+ errContains string
269+ }{
270+ {
271+ name: "/mnt is mounted - postcheck passes",
272+ setupMocks: func(mockSysExec *mocks.MockSystemExecutor) {
273+ mockSysExec.EXPECT().RunSimple("mountpoint", "-q", "/mnt").Return(system.CommandResult{ExitCode: 0}).Times(1)
274+ },
275+ wantErr: false,
276+ },
277+ {
278+ name: "/mnt not mounted - postcheck fails",
279+ setupMocks: func(mockSysExec *mocks.MockSystemExecutor) {
280+ mockSysExec.EXPECT().RunSimple("mountpoint", "-q", "/mnt").Return(system.CommandResult{ExitCode: 32}).Times(1)
281+ },
282+ wantErr: true,
283+ errContains: "is not mounted",
284+ },
285+ }
286+
287+ for _, tt := range tests {
288+ t.Run(tt.name, func(t *testing.T) {
289+ ctrl := gomock.NewController(t)
290+ defer ctrl.Finish()
291+
292+ mockSysExec := mocks.NewMockSystemExecutor(ctrl)
293+
294+ tt.setupMocks(mockSysExec)
295+
296+ tmpDir := t.TempDir()
297+ logPath := filepath.Join(tmpDir, "test.log")
298+ log, err := logger.New(logPath, false)
299+ if err != nil {
300+ t.Fatalf("Failed to create logger: %v", err)
301+ }
302+ defer log.Close()
303+
304+ cfg := config.NewConfig("test")
305+ cfg.ConfigPath = filepath.Join(tmpDir, "config")
306+
307+ phase := NewPartitioningPhase(cfg, log, mockSysExec)
308+
309+ err = phase.PostCheck()
310+ if (err != nil) != tt.wantErr {
311+ t.Errorf("PostCheck() error = %v, wantErr %v", err, tt.wantErr)
312+ return
313+ }
314+ if tt.wantErr && err != nil {
315+ if errMsg := err.Error(); tt.errContains != "" && !containsSubstring(errMsg, tt.errContains) {
316+ t.Errorf("PostCheck() error = %q, should contain %q", errMsg, tt.errContains)
317+ }
318+ }
319+ })
320+ }
321+}
322+
323+// TestPartitioningPhaseRollback tests rollback cleanup
324+func TestPartitioningPhaseRollback(t *testing.T) {
325+ tests := []struct {
326+ name string
327+ setupMocks func(*mocks.MockSystemExecutor)
328+ encryption string
329+ cryptDevice string
330+ wantErr bool
331+ }{
332+ {
333+ name: "Rollback unencrypted - success",
334+ setupMocks: func(mockSysExec *mocks.MockSystemExecutor) {
335+ mockSysExec.EXPECT().RunSimple("umount", "/mnt/boot").Return(system.CommandResult{}).Times(1)
336+ mockSysExec.EXPECT().RunSimple("umount", "/mnt/home").Return(system.CommandResult{}).Times(1)
337+ mockSysExec.EXPECT().RunSimple("umount", "/mnt").Return(system.CommandResult{}).Times(1)
338+ },
339+ encryption: config.EncryptionNone,
340+ cryptDevice: "",
341+ wantErr: false,
342+ },
343+ {
344+ name: "Rollback encrypted - closes LUKS",
345+ setupMocks: func(mockSysExec *mocks.MockSystemExecutor) {
346+ mockSysExec.EXPECT().RunSimple("umount", "/mnt/boot").Return(system.CommandResult{}).Times(1)
347+ mockSysExec.EXPECT().RunSimple("umount", "/mnt/home").Return(system.CommandResult{}).Times(1)
348+ mockSysExec.EXPECT().RunSimple("umount", "/mnt").Return(system.CommandResult{}).Times(1)
349+ mockSysExec.EXPECT().RunSimple("cryptsetup", "close", "cryptroot").Return(system.CommandResult{}).Times(1)
350+ },
351+ encryption: config.EncryptionLUKS,
352+ cryptDevice: "/dev/mapper/cryptroot",
353+ wantErr: false,
354+ },
355+ {
356+ name: "Rollback umount failure - continues anyway",
357+ setupMocks: func(mockSysExec *mocks.MockSystemExecutor) {
358+ mockSysExec.EXPECT().RunSimple("umount", "/mnt/boot").Return(system.CommandResult{ExitCode: 32, Error: fmt.Errorf("not mounted")}).Times(1)
359+ mockSysExec.EXPECT().RunSimple("umount", "/mnt/home").Return(system.CommandResult{ExitCode: 32}).Times(1)
360+ mockSysExec.EXPECT().RunSimple("umount", "/mnt").Return(system.CommandResult{ExitCode: 32}).Times(1)
361+ },
362+ encryption: config.EncryptionNone,
363+ cryptDevice: "",
364+ wantErr: false, // Rollback doesn't fail on umount errors
365+ },
366+ }
367+
368+ for _, tt := range tests {
369+ t.Run(tt.name, func(t *testing.T) {
370+ ctrl := gomock.NewController(t)
371+ defer ctrl.Finish()
372+
373+ mockSysExec := mocks.NewMockSystemExecutor(ctrl)
374+
375+ tt.setupMocks(mockSysExec)
376+
377+ tmpDir := t.TempDir()
378+ logPath := filepath.Join(tmpDir, "test.log")
379+ log, err := logger.New(logPath, false)
380+ if err != nil {
381+ t.Fatalf("Failed to create logger: %v", err)
382+ }
383+ defer log.Close()
384+
385+ cfg := config.NewConfig("test")
386+ cfg.EncryptionType = tt.encryption
387+ cfg.CryptDevice = tt.cryptDevice
388+
389+ phase := NewPartitioningPhase(cfg, log, mockSysExec)
390+
391+ err = phase.Rollback()
392+ if (err != nil) != tt.wantErr {
393+ t.Errorf("Rollback() error = %v, wantErr %v", err, tt.wantErr)
394+ }
395+ })
396+ }
397+}
398+
399+// TestPartitioningPhaseCanSkip tests the CanSkip method
400+func TestPartitioningPhaseCanSkip(t *testing.T) {
401+ ctrl := gomock.NewController(t)
402+ defer ctrl.Finish()
403+
404+ mockSysExec := mocks.NewMockSystemExecutor(ctrl)
405+
406+ tmpDir := t.TempDir()
407+ logPath := filepath.Join(tmpDir, "test.log")
408+ log, err := logger.New(logPath, false)
409+ if err != nil {
410+ t.Fatalf("Failed to create logger: %v", err)
411+ }
412+ defer log.Close()
413+
414+ cfg := config.NewConfig("test")
415+ phase := NewPartitioningPhase(cfg, log, mockSysExec)
416+
417+ if phase.CanSkip() {
418+ t.Error("CanSkip() = true, want false")
419+ }
420+}
421+
422+// TestPartitioningPhaseExecute tests the Execute method structure
423+func TestPartitioningPhaseExecute(t *testing.T) {
424+ ctrl := gomock.NewController(t)
425+ defer ctrl.Finish()
426+
427+ mockSysExec := mocks.NewMockSystemExecutor(ctrl)
428+
429+ tmpDir := t.TempDir()
430+ logPath := filepath.Join(tmpDir, "test.log")
431+ log, err := logger.New(logPath, false)
432+ if err != nil {
433+ t.Fatalf("Failed to create logger: %v", err)
434+ }
435+ defer log.Close()
436+
437+ cfg := config.NewConfig("test")
438+ cfg.TargetDisk = "/dev/sda"
439+
440+ phase := NewPartitioningPhase(cfg, log, mockSysExec)
441+
442+ progressChan := make(chan ProgressUpdate, 100)
443+
444+ // Verify phase can be created and Execute returns a PhaseResult
445+ // Note: Full Execute testing is complex due to logger.ExecCommand() calls
446+ // The return type and structure are validated here
447+ result := phase.Execute(progressChan)
448+
449+ // Verify result has the expected type
450+ if reflect.TypeOf(result) != reflect.TypeOf(PhaseResult{}) {
451+ t.Errorf("Execute() returned unexpected type: %T", result)
452+ }
453+}
454+
455+// TestPartitioningPhaseDeviceHandling tests both encrypted and unencrypted device handling
456+func TestPartitioningPhaseDeviceHandling(t *testing.T) {
457+ tests := []struct {
458+ name string
459+ encryptionType string
460+ cryptDevice string
461+ rootPartition string
462+ expectedDevice string
463+ }{
464+ {
465+ name: "Unencrypted uses root partition",
466+ encryptionType: config.EncryptionNone,
467+ cryptDevice: "",
468+ rootPartition: "/dev/sda2",
469+ expectedDevice: "/dev/sda2",
470+ },
471+ {
472+ name: "Encrypted uses crypt device",
473+ encryptionType: config.EncryptionLUKS,
474+ cryptDevice: "/dev/mapper/cryptroot",
475+ rootPartition: "/dev/sda2",
476+ expectedDevice: "/dev/mapper/cryptroot",
477+ },
478+ {
479+ name: "LUKS+LVM uses crypt device",
480+ encryptionType: config.EncryptionLUKSLVM,
481+ cryptDevice: "/dev/mapper/cryptroot",
482+ rootPartition: "/dev/sda2",
483+ expectedDevice: "/dev/mapper/cryptroot",
484+ },
485+ }
486+
487+ for _, tt := range tests {
488+ t.Run(tt.name, func(t *testing.T) {
489+ ctrl := gomock.NewController(t)
490+ defer ctrl.Finish()
491+
492+ mockSysExec := mocks.NewMockSystemExecutor(ctrl)
493+
494+ tmpDir := t.TempDir()
495+ logPath := filepath.Join(tmpDir, "test.log")
496+ log, err := logger.New(logPath, false)
497+ if err != nil {
498+ t.Fatalf("Failed to create logger: %v", err)
499+ }
500+ defer log.Close()
501+
502+ cfg := config.NewConfig("test")
503+ cfg.EncryptionType = tt.encryptionType
504+ cfg.CryptDevice = tt.cryptDevice
505+ cfg.RootPartition = tt.rootPartition
506+
507+ phase := NewPartitioningPhase(cfg, log, mockSysExec)
508+
509+ // Determine which device would be used
510+ var device string
511+ switch phase.config.EncryptionType {
512+ case config.EncryptionLUKS, config.EncryptionLUKSLVM:
513+ device = phase.config.CryptDevice
514+ default:
515+ device = phase.config.RootPartition
516+ }
517+
518+ if device != tt.expectedDevice {
519+ t.Errorf("Device selection = %q, want %q", device, tt.expectedDevice)
520+ }
521+ })
522+ }
523+}
A internal/phases/phase_test.go +328 -0
@@ -0,0 +1,328 @@
1+package phases
2+
3+import (
4+ "fmt"
5+ "path/filepath"
6+ "testing"
7+
8+ "github.com/bnema/archup/internal/config"
9+ "github.com/bnema/archup/internal/logger"
10+)
11+
12+// TestPhaseStatusString tests the String() method of PhaseStatus enum
13+func TestPhaseStatusString(t *testing.T) {
14+ tests := []struct {
15+ status PhaseStatus
16+ expected string
17+ }{
18+ {StatusPending, "pending"},
19+ {StatusRunning, "running"},
20+ {StatusCompleted, "completed"},
21+ {StatusFailed, "failed"},
22+ {StatusSkipped, "skipped"},
23+ {PhaseStatus(999), "unknown"},
24+ }
25+
26+ for _, tt := range tests {
27+ t.Run(tt.expected, func(t *testing.T) {
28+ got := tt.status.String()
29+ if got != tt.expected {
30+ t.Errorf("PhaseStatus.String() = %q, want %q", got, tt.expected)
31+ }
32+ })
33+ }
34+}
35+
36+// TestNewBasePhase verifies BasePhase initialization
37+func TestNewBasePhase(t *testing.T) {
38+ tmpDir := t.TempDir()
39+ logPath := filepath.Join(tmpDir, "test.log")
40+
41+ log, err := logger.New(logPath, false)
42+ if err != nil {
43+ t.Fatalf("Failed to create logger: %v", err)
44+ }
45+ defer log.Close()
46+
47+ cfg := config.NewConfig("test")
48+
49+ phase := NewBasePhase("test-phase", "Test phase description", cfg, log)
50+
51+ if phase.Name() != "test-phase" {
52+ t.Errorf("Name() = %q, want %q", phase.Name(), "test-phase")
53+ }
54+
55+ if phase.Description() != "Test phase description" {
56+ t.Errorf("Description() = %q, want %q", phase.Description(), "Test phase description")
57+ }
58+
59+ if phase.Status() != StatusPending {
60+ t.Errorf("Status() = %v, want %v", phase.Status(), StatusPending)
61+ }
62+
63+ if phase.Config() != cfg {
64+ t.Errorf("Config() returned different instance")
65+ }
66+
67+ if phase.Logger() != log {
68+ t.Errorf("Logger() returned different instance")
69+ }
70+}
71+
72+// TestBasePhaseSetStatus verifies status updates
73+func TestBasePhaseSetStatus(t *testing.T) {
74+ tmpDir := t.TempDir()
75+ logPath := filepath.Join(tmpDir, "test.log")
76+
77+ log, _ := logger.New(logPath, false)
78+ defer log.Close()
79+
80+ cfg := config.NewConfig("test")
81+ phase := NewBasePhase("test", "test", cfg, log)
82+
83+ statuses := []PhaseStatus{
84+ StatusPending,
85+ StatusRunning,
86+ StatusCompleted,
87+ StatusFailed,
88+ StatusSkipped,
89+ }
90+
91+ for _, status := range statuses {
92+ t.Run(status.String(), func(t *testing.T) {
93+ phase.SetStatus(status)
94+ if phase.Status() != status {
95+ t.Errorf("Status() = %v, want %v", phase.Status(), status)
96+ }
97+ })
98+ }
99+}
100+
101+// TestBasePhaseDefaultPreCheck verifies default PreCheck implementation
102+func TestBasePhaseDefaultPreCheck(t *testing.T) {
103+ tmpDir := t.TempDir()
104+ logPath := filepath.Join(tmpDir, "test.log")
105+
106+ log, _ := logger.New(logPath, false)
107+ defer log.Close()
108+
109+ tests := []struct {
110+ name string
111+ cfg *config.Config
112+ wantError bool
113+ }{
114+ {
115+ name: "with valid config",
116+ cfg: config.NewConfig("test"),
117+ wantError: false,
118+ },
119+ {
120+ name: "with nil config",
121+ cfg: nil,
122+ wantError: true,
123+ },
124+ }
125+
126+ for _, tt := range tests {
127+ t.Run(tt.name, func(t *testing.T) {
128+ phase := NewBasePhase("test", "test", tt.cfg, log)
129+ err := phase.PreCheck()
130+
131+ if (err != nil) != tt.wantError {
132+ t.Errorf("PreCheck() error = %v, wantError %v", err, tt.wantError)
133+ }
134+ })
135+ }
136+}
137+
138+// TestBasePhaseDefaultPostCheck verifies default PostCheck (always succeeds)
139+func TestBasePhaseDefaultPostCheck(t *testing.T) {
140+ tmpDir := t.TempDir()
141+ logPath := filepath.Join(tmpDir, "test.log")
142+
143+ log, _ := logger.New(logPath, false)
144+ defer log.Close()
145+
146+ cfg := config.NewConfig("test")
147+ phase := NewBasePhase("test", "test", cfg, log)
148+
149+ err := phase.PostCheck()
150+ if err != nil {
151+ t.Errorf("PostCheck() error = %v, want nil", err)
152+ }
153+}
154+
155+// TestBasePhaseDefaultRollback verifies default Rollback returns error
156+func TestBasePhaseDefaultRollback(t *testing.T) {
157+ tmpDir := t.TempDir()
158+ logPath := filepath.Join(tmpDir, "test.log")
159+
160+ log, _ := logger.New(logPath, false)
161+ defer log.Close()
162+
163+ cfg := config.NewConfig("test")
164+ phase := NewBasePhase("my-phase", "test", cfg, log)
165+
166+ err := phase.Rollback()
167+ if err == nil {
168+ t.Error("Rollback() error = nil, want error")
169+ }
170+
171+ expectedMsg := "rollback not implemented for phase: my-phase"
172+ if err.Error() != expectedMsg {
173+ t.Errorf("Rollback() error = %q, want %q", err.Error(), expectedMsg)
174+ }
175+}
176+
177+// TestBasePhaseDefaultCanSkip verifies default CanSkip returns false
178+func TestBasePhaseDefaultCanSkip(t *testing.T) {
179+ tmpDir := t.TempDir()
180+ logPath := filepath.Join(tmpDir, "test.log")
181+
182+ log, _ := logger.New(logPath, false)
183+ defer log.Close()
184+
185+ cfg := config.NewConfig("test")
186+ phase := NewBasePhase("test", "test", cfg, log)
187+
188+ if phase.CanSkip() {
189+ t.Error("CanSkip() = true, want false")
190+ }
191+}
192+
193+// TestSendProgress verifies progress updates sent to channel
194+func TestSendProgress(t *testing.T) {
195+ tmpDir := t.TempDir()
196+ logPath := filepath.Join(tmpDir, "test.log")
197+
198+ log, _ := logger.New(logPath, false)
199+ defer log.Close()
200+
201+ cfg := config.NewConfig("test")
202+ phase := NewBasePhase("test-phase", "test", cfg, log)
203+
204+ progressChan := make(chan ProgressUpdate, 5)
205+
206+ phase.SendProgress(progressChan, "step-1", 1, 3)
207+ phase.SendProgress(progressChan, "step-2", 2, 3)
208+
209+ // Verify first update
210+ update1 := <-progressChan
211+ if update1.PhaseName != "test-phase" {
212+ t.Errorf("PhaseName = %q, want %q", update1.PhaseName, "test-phase")
213+ }
214+ if update1.Step != "step-1" {
215+ t.Errorf("Step = %q, want %q", update1.Step, "step-1")
216+ }
217+ if update1.Current != 1 || update1.Total != 3 {
218+ t.Errorf("Progress = %d/%d, want 1/3", update1.Current, update1.Total)
219+ }
220+ if update1.IsComplete || update1.IsError {
221+ t.Error("Flags should be false for regular progress update")
222+ }
223+
224+ // Verify second update
225+ update2 := <-progressChan
226+ if update2.Step != "step-2" {
227+ t.Errorf("Step = %q, want %q", update2.Step, "step-2")
228+ }
229+ if update2.Current != 2 {
230+ t.Errorf("Current = %d, want 2", update2.Current)
231+ }
232+}
233+
234+// TestSendProgressWithNilChannel verifies no panic with nil channel
235+func TestSendProgressWithNilChannel(t *testing.T) {
236+ tmpDir := t.TempDir()
237+ logPath := filepath.Join(tmpDir, "test.log")
238+
239+ log, _ := logger.New(logPath, false)
240+ defer log.Close()
241+
242+ cfg := config.NewConfig("test")
243+ phase := NewBasePhase("test", "test", cfg, log)
244+
245+ // Should not panic with nil channel
246+ phase.SendProgress(nil, "step", 1, 2)
247+}
248+
249+// TestSendComplete verifies completion signal
250+func TestSendComplete(t *testing.T) {
251+ tmpDir := t.TempDir()
252+ logPath := filepath.Join(tmpDir, "test.log")
253+
254+ log, _ := logger.New(logPath, false)
255+ defer log.Close()
256+
257+ cfg := config.NewConfig("test")
258+ phase := NewBasePhase("test-phase", "test", cfg, log)
259+
260+ progressChan := make(chan ProgressUpdate, 1)
261+
262+ phase.SendComplete(progressChan, "Installation complete")
263+
264+ update := <-progressChan
265+ if !update.IsComplete {
266+ t.Error("IsComplete = false, want true")
267+ }
268+ if update.IsError {
269+ t.Error("IsError = true, want false")
270+ }
271+ if update.Step != "Installation complete" {
272+ t.Errorf("Step = %q, want %q", update.Step, "Installation complete")
273+ }
274+}
275+
276+// TestSendError verifies error signal
277+func TestSendError(t *testing.T) {
278+ tmpDir := t.TempDir()
279+ logPath := filepath.Join(tmpDir, "test.log")
280+
281+ log, _ := logger.New(logPath, false)
282+ defer log.Close()
283+
284+ cfg := config.NewConfig("test")
285+ phase := NewBasePhase("test-phase", "test", cfg, log)
286+
287+ progressChan := make(chan ProgressUpdate, 1)
288+
289+ testErr := "test error message"
290+ err := fmt.Errorf("%s", testErr)
291+ phase.SendError(progressChan, err)
292+
293+ update := <-progressChan
294+ if !update.IsComplete {
295+ t.Error("IsComplete = false, want true")
296+ }
297+ if !update.IsError {
298+ t.Error("IsError = false, want true")
299+ }
300+ if update.ErrorMsg != testErr {
301+ t.Errorf("ErrorMsg = %q, want %q", update.ErrorMsg, testErr)
302+ }
303+}
304+
305+// TestSendOutput verifies output line sending
306+func TestSendOutput(t *testing.T) {
307+ tmpDir := t.TempDir()
308+ logPath := filepath.Join(tmpDir, "test.log")
309+
310+ log, _ := logger.New(logPath, false)
311+ defer log.Close()
312+
313+ cfg := config.NewConfig("test")
314+ phase := NewBasePhase("test-phase", "test", cfg, log)
315+
316+ progressChan := make(chan ProgressUpdate, 1)
317+
318+ outputLine := "output from command"
319+ phase.SendOutput(progressChan, outputLine)
320+
321+ update := <-progressChan
322+ if update.OutputLine != outputLine {
323+ t.Errorf("OutputLine = %q, want %q", update.OutputLine, outputLine)
324+ }
325+ if update.IsComplete || update.IsError {
326+ t.Error("Completion/Error flags should be false for output update")
327+ }
328+}
M internal/phases/postinstall.go +54 -45
@@ -10,6 +10,7 @@ import (
1010 "strings"
1111
1212 "github.com/bnema/archup/internal/config"
13+ "github.com/bnema/archup/internal/interfaces"
1314 "github.com/bnema/archup/internal/logger"
1415 "github.com/bnema/archup/internal/system"
1516 )
@@ -17,27 +18,35 @@ import (
1718 // PostInstallPhase handles post-installation tasks
1819 type PostInstallPhase struct {
1920 *BasePhase
21+ fs interfaces.FileSystem
22+ http interfaces.HTTPClient
23+ sysExec interfaces.SystemExecutor
24+ chrExec interfaces.ChrootExecutor
2025 }
2126
2227 // NewPostInstallPhase creates a new post-install phase
23-func NewPostInstallPhase(cfg *config.Config, log *logger.Logger) *PostInstallPhase {
28+func NewPostInstallPhase(cfg *config.Config, log *logger.Logger, fs interfaces.FileSystem, http interfaces.HTTPClient, sysExec interfaces.SystemExecutor, chrExec interfaces.ChrootExecutor) *PostInstallPhase {
2429 return &PostInstallPhase{
2530 BasePhase: NewBasePhase("postinstall", "Post-Installation", cfg, log),
31+ fs: fs,
32+ http: http,
33+ sysExec: sysExec,
34+ chrExec: chrExec,
2635 }
2736 }
2837
2938 // PreCheck validates post-install prerequisites
3039 func (p *PostInstallPhase) PreCheck() error {
3140 // Verify /mnt is mounted
32- result := system.RunSimple("mountpoint", "-q", config.PathMnt)
41+ result := p.sysExec.RunSimple("mountpoint", "-q", config.PathMnt)
3342 switch {
3443 case result.ExitCode != 0:
3544 return fmt.Errorf("%s is not mounted", config.PathMnt)
3645 }
3746
3847 // Verify boot directory exists
39- switch _, err := os.Stat(config.PathMntBoot); {
40- case os.IsNotExist(err):
48+ switch _, err := p.fs.Stat(config.PathMntBoot); {
49+ case p.fs.IsNotExist(err):
4150 return fmt.Errorf("boot directory not found")
4251 }
4352
@@ -140,7 +149,7 @@ func (p *PostInstallPhase) installBootLogo(progressChan chan<- ProgressUpdate) e
140149 // Build logo URL from config
141150 logoURL := fmt.Sprintf("%s/%s", p.config.RawURL, config.ArchLogoURL)
142151
143- resp, err := http.Get(logoURL)
152+ resp, err := p.http.Get(logoURL)
144153 switch {
145154 case err != nil:
146155 return fmt.Errorf("failed to download logo: %w", err)
@@ -153,7 +162,7 @@ func (p *PostInstallPhase) installBootLogo(progressChan chan<- ProgressUpdate) e
153162 }
154163
155164 // Save to boot partition
156- logoFile, err := os.Create(config.PathMntBootLogo)
165+ logoFile, err := p.fs.Create(config.PathMntBootLogo)
157166 switch {
158167 case err != nil:
159168 return fmt.Errorf("failed to create logo file: %w", err)
@@ -170,7 +179,7 @@ func (p *PostInstallPhase) installBootLogo(progressChan chan<- ProgressUpdate) e
170179
171180 // Update Limine config
172181 limineConf := filepath.Join(config.PathMntBootEFILimine, config.FileLimineConfig)
173- content, err := os.ReadFile(limineConf)
182+ content, err := p.fs.ReadFile(limineConf)
174183 switch {
175184 case err != nil:
176185 return fmt.Errorf("failed to read limine.conf: %w", err)
@@ -185,7 +194,7 @@ func (p *PostInstallPhase) installBootLogo(progressChan chan<- ProgressUpdate) e
185194 wallpaperSettings := "\nwallpaper: boot():/arch-logo.png\nwallpaper_style: centered\nbackdrop: 000000"
186195 contentStr = graphicsRegex.ReplaceAllString(contentStr, "graphics: yes"+wallpaperSettings)
187196
188- switch err := os.WriteFile(limineConf, []byte(contentStr), 0644); {
197+ switch err := p.fs.WriteFile(limineConf, []byte(contentStr), 0644); {
189198 case err != nil:
190199 return fmt.Errorf("failed to update limine.conf: %w", err)
191200 }
@@ -204,7 +213,7 @@ func (p *PostInstallPhase) configurePlymouth(progressChan chan<- ProgressUpdate)
204213
205214 // Create theme directory
206215 themeDir := filepath.Join(config.PathMntPlymouthThemes, config.PlymouthThemeName)
207- switch err := os.MkdirAll(themeDir, 0755); {
216+ switch err := p.fs.MkdirAll(themeDir, 0755); {
208217 case err != nil:
209218 return fmt.Errorf("failed to create theme directory: %w", err)
210219 }
@@ -214,7 +223,7 @@ func (p *PostInstallPhase) configurePlymouth(progressChan chan<- ProgressUpdate)
214223 p.SendOutput(progressChan, fmt.Sprintf("Downloading %s...", filename))
215224
216225 fileURL := fmt.Sprintf("%s/assets/plymouth/%s", p.config.RawURL, filename)
217- resp, err := http.Get(fileURL)
226+ resp, err := p.http.Get(fileURL)
218227 switch {
219228 case err != nil:
220229 return fmt.Errorf("failed to download %s: %w", filename, err)
@@ -228,7 +237,7 @@ func (p *PostInstallPhase) configurePlymouth(progressChan chan<- ProgressUpdate)
228237
229238 // Save file
230239 destPath := filepath.Join(themeDir, filename)
231- destFile, err := os.Create(destPath)
240+ destFile, err := p.fs.Create(destPath)
232241 switch {
233242 case err != nil:
234243 resp.Body.Close()
@@ -245,7 +254,7 @@ func (p *PostInstallPhase) configurePlymouth(progressChan chan<- ProgressUpdate)
245254 }
246255
247256 // Set permissions
248- switch err := os.Chmod(destPath, 0644); {
257+ switch err := p.fs.Chmod(destPath, 0644); {
249258 case err != nil:
250259 return fmt.Errorf("failed to set permissions on %s: %w", filename, err)
251260 }
@@ -255,7 +264,7 @@ func (p *PostInstallPhase) configurePlymouth(progressChan chan<- ProgressUpdate)
255264
256265 // Set as default theme
257266 setThemeCmd := fmt.Sprintf("plymouth-set-default-theme %s", config.PlymouthThemeName)
258- switch err := system.ChrootExec(p.logger.LogPath(),config.PathMnt, setThemeCmd); {
267+ switch err := p.chrExec.ChrootExec(p.logger.LogPath(),config.PathMnt, setThemeCmd); {
259268 case err != nil:
260269 return fmt.Errorf("failed to set Plymouth theme: %w", err)
261270 }
@@ -264,7 +273,7 @@ func (p *PostInstallPhase) configurePlymouth(progressChan chan<- ProgressUpdate)
264273
265274 // Regenerate initramfs
266275 p.SendOutput(progressChan, "Regenerating initramfs with Plymouth...")
267- switch err := system.ChrootExec(p.logger.LogPath(),config.PathMnt, "mkinitcpio -P"); {
276+ switch err := p.chrExec.ChrootExec(p.logger.LogPath(),config.PathMnt, "mkinitcpio -P"); {
268277 case err != nil:
269278 return fmt.Errorf("failed to regenerate initramfs: %w", err)
270279 }
@@ -278,7 +287,7 @@ func (p *PostInstallPhase) configureSnapper(progressChan chan<- ProgressUpdate)
278287 p.SendOutput(progressChan, "Installing limine-snapper-sync...")
279288
280289 // Install limine-snapper-sync
281- switch err := system.ChrootPacman(p.logger.LogPath(),config.PathMnt, "-S", "--needed", "limine-snapper-sync"); {
290+ switch err := p.chrExec.ChrootPacman(p.logger.LogPath(),config.PathMnt, "-S", "--needed", "limine-snapper-sync"); {
282291 case err != nil:
283292 return fmt.Errorf("failed to install limine-snapper-sync: %w", err)
284293 }
@@ -287,7 +296,7 @@ func (p *PostInstallPhase) configureSnapper(progressChan chan<- ProgressUpdate)
287296
288297 // Get kernel cmdline from existing Limine config
289298 limineConf := filepath.Join(config.PathMntBootEFILimine, config.FileLimineConfig)
290- content, err := os.ReadFile(limineConf)
299+ content, err := p.fs.ReadFile(limineConf)
291300 switch {
292301 case err != nil:
293302 return fmt.Errorf("failed to read limine.conf: %w", err)
@@ -321,7 +330,7 @@ MAX_SNAPSHOT_ENTRIES=5
321330 SNAPSHOT_FORMAT_CHOICE=5
322331 `, cmdline)
323332
324- switch err := os.WriteFile(config.PathMntEtcDefaultLimine, []byte(limineDefaultConfig), 0644); {
333+ switch err := p.fs.WriteFile(config.PathMntEtcDefaultLimine, []byte(limineDefaultConfig), 0644); {
325334 case err != nil:
326335 return fmt.Errorf("failed to write limine config: %w", err)
327336 }
@@ -329,7 +338,7 @@ SNAPSHOT_FORMAT_CHOICE=5
329338 p.SendOutput(progressChan, "[OK] Limine snapper config created")
330339
331340 // Enable service
332- switch err := system.ChrootSystemctl(p.logger.LogPath(), config.PathMnt, "enable", "limine-snapper-sync.service"); {
341+ switch err := p.chrExec.ChrootSystemctl(p.logger.LogPath(), config.PathMnt, "enable", "limine-snapper-sync.service"); {
333342 case err != nil:
334343 return fmt.Errorf("failed to enable limine-snapper-sync: %w", err)
335344 }
@@ -342,7 +351,7 @@ SNAPSHOT_FORMAT_CHOICE=5
342351 func (p *PostInstallPhase) configurePacman(progressChan chan<- ProgressUpdate) error {
343352 p.SendOutput(progressChan, "Configuring pacman UX...")
344353
345- content, err := os.ReadFile(config.PathMntEtcPacmanConf)
354+ content, err := p.fs.ReadFile(config.PathMntEtcPacmanConf)
346355 switch {
347356 case err != nil:
348357 return fmt.Errorf("failed to read pacman.conf: %w", err)
@@ -365,7 +374,7 @@ func (p *PostInstallPhase) configurePacman(progressChan chan<- ProgressUpdate) e
365374 contentStr = colorLineRegex.ReplaceAllString(contentStr, "Color\nILoveCandy")
366375 }
367376
368- switch err := os.WriteFile(config.PathMntEtcPacmanConf, []byte(contentStr), 0644); {
377+ switch err := p.fs.WriteFile(config.PathMntEtcPacmanConf, []byte(contentStr), 0644); {
369378 case err != nil:
370379 return fmt.Errorf("failed to write pacman.conf: %w", err)
371380 }
@@ -379,7 +388,7 @@ func (p *PostInstallPhase) installPacmanHooks(progressChan chan<- ProgressUpdate
379388 p.SendOutput(progressChan, "Installing pacman hooks...")
380389
381390 // Create hooks directory
382- switch err := os.MkdirAll(config.PathMntEtcPacmanDHooks, 0755); {
391+ switch err := p.fs.MkdirAll(config.PathMntEtcPacmanDHooks, 0755); {
383392 case err != nil:
384393 return fmt.Errorf("failed to create hooks directory: %w", err)
385394 }
@@ -398,7 +407,7 @@ Exec = /usr/bin/cp /usr/share/limine/BOOTX64.EFI /boot/EFI/limine/
398407 `
399408
400409 hookPath := filepath.Join(config.PathMntEtcPacmanDHooks, "99-limine.hook")
401- switch err := os.WriteFile(hookPath, []byte(limineHook), 0644); {
410+ switch err := p.fs.WriteFile(hookPath, []byte(limineHook), 0644); {
402411 case err != nil:
403412 return fmt.Errorf("failed to write limine hook: %w", err)
404413 }
@@ -429,7 +438,7 @@ func (p *PostInstallPhase) configureShell(progressChan chan<- ProgressUpdate) er
429438 p.SendOutput(progressChan, fmt.Sprintf("Configuring shell for user: %s", username))
430439
431440 // Create directory structure
432- switch err := os.MkdirAll(archupDefaultBash, 0755); {
441+ switch err := p.fs.MkdirAll(archupDefaultBash, 0755); {
433442 case err != nil:
434443 return fmt.Errorf("failed to create shell config directory: %w", err)
435444 }
@@ -448,7 +457,7 @@ func (p *PostInstallPhase) configureShell(progressChan chan<- ProgressUpdate) er
448457 }
449458
450459 bashrcPath := filepath.Join(userHome, ".bashrc")
451- switch err := os.WriteFile(bashrcPath, bashrcContent, 0644); {
460+ switch err := p.fs.WriteFile(bashrcPath, bashrcContent, 0644); {
452461 case err != nil:
453462 return fmt.Errorf("failed to write .bashrc: %w", err)
454463 }
@@ -471,7 +480,7 @@ func (p *PostInstallPhase) configureShell(progressChan chan<- ProgressUpdate) er
471480 }
472481
473482 for _, gitCmd := range gitCommands {
474- switch err := system.ChrootExec(p.logger.LogPath(),config.PathMnt, gitCmd); {
483+ switch err := p.chrExec.ChrootExec(p.logger.LogPath(),config.PathMnt, gitCmd); {
475484 case err != nil:
476485 // Non-fatal
477486 p.SendOutput(progressChan, "[WARN] Failed to configure git delta")
@@ -501,7 +510,7 @@ func (p *PostInstallPhase) copyShellTemplates(archupDefault, archupDefaultBash s
501510 return fmt.Errorf("failed to read template %s: %w", template, err)
502511 }
503512
504- switch err := os.WriteFile(dest, content, 0644); {
513+ switch err := p.fs.WriteFile(dest, content, 0644); {
505514 case err != nil:
506515 return fmt.Errorf("failed to write %s: %w", dest, err)
507516 }
@@ -514,7 +523,7 @@ func (p *PostInstallPhase) copyShellTemplates(archupDefault, archupDefaultBash s
514523 func (p *PostInstallPhase) readTemplate(filename string) ([]byte, error) {
515524 // Use DefaultInstallDir directly to match where bootstrap downloads files
516525 templatePath := filepath.Join(config.DefaultInstallDir, filename)
517- return os.ReadFile(templatePath)
526+ return p.fs.ReadFile(templatePath)
518527 }
519528
520529 // setShellOwnership sets ownership of shell config files to user
@@ -523,7 +532,7 @@ func (p *PostInstallPhase) setShellOwnership(userHome, username string) error {
523532 relativeHome := strings.TrimPrefix(userHome, config.PathMnt)
524533 chownCmd := fmt.Sprintf("chown -R %s:%s %s/.local %s/.bashrc",
525534 username, username, relativeHome, relativeHome)
526- return system.ChrootExec(p.logger.LogPath(),config.PathMnt, chownCmd)
535+ return p.chrExec.ChrootExec(p.logger.LogPath(),config.PathMnt, chownCmd)
527536 }
528537
529538 // verifyInstallation performs comprehensive verification
@@ -535,8 +544,8 @@ func (p *PostInstallPhase) verifyInstallation(progressChan chan<- ProgressUpdate
535544 // Verify kernel
536545 kernelName := p.config.KernelChoice
537546 kernelPath := filepath.Join(config.PathMntBoot, fmt.Sprintf("vmlinuz-%s", kernelName))
538- switch _, err := os.Stat(kernelPath); {
539- case os.IsNotExist(err):
547+ switch _, err := p.fs.Stat(kernelPath); {
548+ case p.fs.IsNotExist(err):
540549 p.SendOutput(progressChan, fmt.Sprintf("[ERROR] Kernel missing: %s", kernelPath))
541550 verificationFailed = true
542551 default:
@@ -545,8 +554,8 @@ func (p *PostInstallPhase) verifyInstallation(progressChan chan<- ProgressUpdate
545554
546555 // Verify initramfs
547556 initramfsPath := filepath.Join(config.PathMntBoot, fmt.Sprintf("initramfs-%s.img", kernelName))
548- switch _, err := os.Stat(initramfsPath); {
549- case os.IsNotExist(err):
557+ switch _, err := p.fs.Stat(initramfsPath); {
558+ case p.fs.IsNotExist(err):
550559 p.SendOutput(progressChan, fmt.Sprintf("[ERROR] Initramfs missing: %s", initramfsPath))
551560 verificationFailed = true
552561 default:
@@ -555,8 +564,8 @@ func (p *PostInstallPhase) verifyInstallation(progressChan chan<- ProgressUpdate
555564
556565 // Verify bootloader
557566 bootloaderPath := filepath.Join(config.PathMntBootEFILimine, config.FileLimineBootloader)
558- switch _, err := os.Stat(bootloaderPath); {
559- case os.IsNotExist(err):
567+ switch _, err := p.fs.Stat(bootloaderPath); {
568+ case p.fs.IsNotExist(err):
560569 p.SendOutput(progressChan, "[ERROR] Bootloader missing")
561570 verificationFailed = true
562571 default:
@@ -565,7 +574,7 @@ func (p *PostInstallPhase) verifyInstallation(progressChan chan<- ProgressUpdate
565574
566575 // Verify Limine config
567576 limineConfPath := filepath.Join(config.PathMntBootEFILimine, config.FileLimineConfig)
568- switch content, err := os.ReadFile(limineConfPath); {
577+ switch content, err := p.fs.ReadFile(limineConfPath); {
569578 case err != nil:
570579 p.SendOutput(progressChan, "[ERROR] Limine config missing")
571580 verificationFailed = true
@@ -580,7 +589,7 @@ func (p *PostInstallPhase) verifyInstallation(progressChan chan<- ProgressUpdate
580589 }
581590
582591 // Verify NetworkManager is enabled
583- result := system.RunSimple("arch-chroot", config.PathMnt, "systemctl", "is-enabled", "NetworkManager")
592+ result := p.sysExec.RunSimple("arch-chroot", config.PathMnt, "systemctl", "is-enabled", "NetworkManager")
584593 switch {
585594 case result.ExitCode != 0:
586595 p.SendOutput(progressChan, "[ERROR] NetworkManager not enabled")
@@ -651,7 +660,7 @@ func (p *PostInstallPhase) CanSkip() bool {
651660 func (p *PostInstallPhase) setupPostBoot(progressChan chan<- ProgressUpdate) error {
652661 p.SendOutput(progressChan, "Configuring first-boot service...")
653662
654- switch err := os.MkdirAll(config.PathMntPostBoot, 0755); {
663+ switch err := p.fs.MkdirAll(config.PathMntPostBoot, 0755); {
655664 case err != nil:
656665 return fmt.Errorf("failed to create post-boot directory: %w", err)
657666 }
@@ -659,7 +668,7 @@ func (p *PostInstallPhase) setupPostBoot(progressChan chan<- ProgressUpdate) err
659668 // Download logo.txt
660669 p.SendOutput(progressChan, "Downloading logo.txt...")
661670 logoURL := fmt.Sprintf("%s/logo.txt", p.config.RawURL)
662- resp, err := http.Get(logoURL)
671+ resp, err := p.http.Get(logoURL)
663672 switch {
664673 case err != nil:
665674 return fmt.Errorf("failed to download logo.txt: %w", err)
@@ -669,7 +678,7 @@ func (p *PostInstallPhase) setupPostBoot(progressChan chan<- ProgressUpdate) err
669678 }
670679
671680 logoPath := filepath.Join(config.PathMntPostBoot, "logo.txt")
672- logoFile, err := os.Create(logoPath)
681+ logoFile, err := p.fs.Create(logoPath)
673682 switch {
674683 case err != nil:
675684 resp.Body.Close()
@@ -689,7 +698,7 @@ func (p *PostInstallPhase) setupPostBoot(progressChan chan<- ProgressUpdate) err
689698 p.SendOutput(progressChan, fmt.Sprintf("Downloading %s...", script))
690699
691700 scriptURL := fmt.Sprintf("%s/install/post-boot/%s", p.config.RawURL, script)
692- resp, err := http.Get(scriptURL)
701+ resp, err := p.http.Get(scriptURL)
693702 switch {
694703 case err != nil:
695704 return fmt.Errorf("failed to download %s: %w", script, err)
@@ -699,7 +708,7 @@ func (p *PostInstallPhase) setupPostBoot(progressChan chan<- ProgressUpdate) err
699708 }
700709
701710 scriptPath := filepath.Join(config.PathMntPostBoot, script)
702- scriptFile, err := os.Create(scriptPath)
711+ scriptFile, err := p.fs.Create(scriptPath)
703712 switch {
704713 case err != nil:
705714 resp.Body.Close()
@@ -715,7 +724,7 @@ func (p *PostInstallPhase) setupPostBoot(progressChan chan<- ProgressUpdate) err
715724 return fmt.Errorf("failed to save %s: %w", script, err)
716725 }
717726
718- switch err := os.Chmod(scriptPath, 0755); {
727+ switch err := p.fs.Chmod(scriptPath, 0755); {
719728 case err != nil:
720729 return fmt.Errorf("failed to set permissions on %s: %w", script, err)
721730 }
@@ -726,7 +735,7 @@ func (p *PostInstallPhase) setupPostBoot(progressChan chan<- ProgressUpdate) err
726735 // Download service template
727736 p.SendOutput(progressChan, "Creating systemd service...")
728737 serviceURL := fmt.Sprintf("%s/%s", p.config.RawURL, config.PostBootServiceTemplate)
729- resp, err = http.Get(serviceURL)
738+ resp, err = p.http.Get(serviceURL)
730739 switch {
731740 case err != nil:
732741 return fmt.Errorf("failed to download service template: %w", err)
@@ -747,7 +756,7 @@ func (p *PostInstallPhase) setupPostBoot(progressChan chan<- ProgressUpdate) err
747756 serviceContent = strings.ReplaceAll(serviceContent, "__ARCHUP_USERNAME__", p.config.Username)
748757
749758 servicePath := filepath.Join(config.PathMntSystemdSystem, config.PostBootServiceName)
750- switch err := os.WriteFile(servicePath, []byte(serviceContent), 0644); {
759+ switch err := p.fs.WriteFile(servicePath, []byte(serviceContent), 0644); {
751760 case err != nil:
752761 return fmt.Errorf("failed to write service file: %w", err)
753762 }
@@ -755,7 +764,7 @@ func (p *PostInstallPhase) setupPostBoot(progressChan chan<- ProgressUpdate) err
755764 p.SendOutput(progressChan, "[OK] Service file created")
756765
757766 p.SendOutput(progressChan, "Enabling first-boot service...")
758- switch err := system.ChrootSystemctl(p.logger.LogPath(), config.PathMnt, "enable", config.PostBootServiceName); {
767+ switch err := p.chrExec.ChrootSystemctl(p.logger.LogPath(), config.PathMnt, "enable", config.PostBootServiceName); {
759768 case err != nil:
760769 return fmt.Errorf("failed to enable service: %w", err)
761770 }
A internal/phases/postinstall_test.go +465 -0
@@ -0,0 +1,465 @@
1+package phases
2+
3+import (
4+ "fmt"
5+ "path/filepath"
6+ "testing"
7+
8+ "github.com/bnema/archup/internal/config"
9+ "github.com/bnema/archup/internal/interfaces/mocks"
10+ "github.com/bnema/archup/internal/logger"
11+ "github.com/bnema/archup/internal/system"
12+ "go.uber.org/mock/gomock"
13+)
14+
15+// TestPostInstallPhasePreCheck tests post-install prerequisites
16+func TestPostInstallPhasePreCheck(t *testing.T) {
17+ tests := []struct {
18+ name string
19+ setupMocks func(*mocks.MockFileSystem, *mocks.MockSystemExecutor)
20+ wantErr bool
21+ errContains string
22+ }{
23+ {
24+ name: "/mnt mounted and boot directory exists",
25+ setupMocks: func(mockFS *mocks.MockFileSystem, mockSysExec *mocks.MockSystemExecutor) {
26+ mockSysExec.EXPECT().RunSimple("mountpoint", "-q", config.PathMnt).Return(system.CommandResult{ExitCode: 0}).Times(1)
27+ mockFS.EXPECT().Stat(config.PathMntBoot).Return(mockFileInfo{}, nil).Times(1)
28+ mockFS.EXPECT().IsNotExist(gomock.Any()).Return(false).Times(1)
29+ },
30+ wantErr: false,
31+ },
32+ {
33+ name: "/mnt not mounted",
34+ setupMocks: func(mockFS *mocks.MockFileSystem, mockSysExec *mocks.MockSystemExecutor) {
35+ mockSysExec.EXPECT().RunSimple("mountpoint", "-q", config.PathMnt).Return(system.CommandResult{ExitCode: 32}).Times(1)
36+ },
37+ wantErr: true,
38+ errContains: "is not mounted",
39+ },
40+ {
41+ name: "boot directory not found",
42+ setupMocks: func(mockFS *mocks.MockFileSystem, mockSysExec *mocks.MockSystemExecutor) {
43+ mockSysExec.EXPECT().RunSimple("mountpoint", "-q", config.PathMnt).Return(system.CommandResult{ExitCode: 0}).Times(1)
44+ mockFS.EXPECT().Stat(config.PathMntBoot).Return(nil, fmt.Errorf("not found")).Times(1)
45+ mockFS.EXPECT().IsNotExist(gomock.Any()).Return(true).Times(1)
46+ },
47+ wantErr: true,
48+ errContains: "boot directory not found",
49+ },
50+ }
51+
52+ for _, tt := range tests {
53+ t.Run(tt.name, func(t *testing.T) {
54+ ctrl := gomock.NewController(t)
55+ defer ctrl.Finish()
56+
57+ mockFS := mocks.NewMockFileSystem(ctrl)
58+ mockHTTP := mocks.NewMockHTTPClient(ctrl)
59+ mockSysExec := mocks.NewMockSystemExecutor(ctrl)
60+ mockChrExec := mocks.NewMockChrootExecutor(ctrl)
61+
62+ tt.setupMocks(mockFS, mockSysExec)
63+
64+ tmpDir := t.TempDir()
65+ logPath := filepath.Join(tmpDir, "test.log")
66+ log, err := logger.New(logPath, false)
67+ if err != nil {
68+ t.Fatalf("Failed to create logger: %v", err)
69+ }
70+ defer log.Close()
71+
72+ cfg := config.NewConfig("test")
73+ phase := NewPostInstallPhase(cfg, log, mockFS, mockHTTP, mockSysExec, mockChrExec)
74+
75+ err = phase.PreCheck()
76+ if (err != nil) != tt.wantErr {
77+ t.Errorf("PreCheck() error = %v, wantErr %v", err, tt.wantErr)
78+ return
79+ }
80+ if tt.wantErr && err != nil {
81+ if errMsg := err.Error(); tt.errContains != "" && !containsSubstring(errMsg, tt.errContains) {
82+ t.Errorf("PreCheck() error = %q, should contain %q", errMsg, tt.errContains)
83+ }
84+ }
85+ })
86+ }
87+}
88+
89+// TestPostInstallPhaseTemplateReading tests template file reading
90+func TestPostInstallPhaseTemplateReading(t *testing.T) {
91+ tests := []struct {
92+ name string
93+ templateFile string
94+ setupMocks func(*mocks.MockFileSystem)
95+ wantErr bool
96+ errContains string
97+ }{
98+ {
99+ name: "Successfully read bashrc template",
100+ templateFile: "bashrc",
101+ setupMocks: func(mockFS *mocks.MockFileSystem) {
102+ mockFS.EXPECT().ReadFile(gomock.Any()).Return([]byte("bashrc content"), nil).Times(1)
103+ },
104+ wantErr: false,
105+ },
106+ {
107+ name: "Template file not found",
108+ templateFile: "missing.conf",
109+ setupMocks: func(mockFS *mocks.MockFileSystem) {
110+ mockFS.EXPECT().ReadFile(gomock.Any()).Return(nil, fmt.Errorf("file not found")).Times(1)
111+ },
112+ wantErr: true,
113+ errContains: "failed to read",
114+ },
115+ }
116+
117+ for _, tt := range tests {
118+ t.Run(tt.name, func(t *testing.T) {
119+ ctrl := gomock.NewController(t)
120+ defer ctrl.Finish()
121+
122+ mockFS := mocks.NewMockFileSystem(ctrl)
123+ mockHTTP := mocks.NewMockHTTPClient(ctrl)
124+ mockSysExec := mocks.NewMockSystemExecutor(ctrl)
125+ mockChrExec := mocks.NewMockChrootExecutor(ctrl)
126+
127+ tt.setupMocks(mockFS)
128+
129+ tmpDir := t.TempDir()
130+ logPath := filepath.Join(tmpDir, "test.log")
131+ log, err := logger.New(logPath, false)
132+ if err != nil {
133+ t.Fatalf("Failed to create logger: %v", err)
134+ }
135+ defer log.Close()
136+
137+ cfg := config.NewConfig("test")
138+ phase := NewPostInstallPhase(cfg, log, mockFS, mockHTTP, mockSysExec, mockChrExec)
139+
140+ _, err = phase.readTemplate(tt.templateFile)
141+ if (err != nil) != tt.wantErr {
142+ t.Errorf("readTemplate() error = %v, wantErr %v", err, tt.wantErr)
143+ return
144+ }
145+ })
146+ }
147+}
148+
149+// TestPostInstallPhasePostCheck tests post-install validation
150+func TestPostInstallPhasePostCheck(t *testing.T) {
151+ ctrl := gomock.NewController(t)
152+ defer ctrl.Finish()
153+
154+ mockFS := mocks.NewMockFileSystem(ctrl)
155+ mockHTTP := mocks.NewMockHTTPClient(ctrl)
156+ mockSysExec := mocks.NewMockSystemExecutor(ctrl)
157+ mockChrExec := mocks.NewMockChrootExecutor(ctrl)
158+
159+ tmpDir := t.TempDir()
160+ logPath := filepath.Join(tmpDir, "test.log")
161+ log, err := logger.New(logPath, false)
162+ if err != nil {
163+ t.Fatalf("Failed to create logger: %v", err)
164+ }
165+ defer log.Close()
166+
167+ cfg := config.NewConfig("test")
168+ cfg.ConfigPath = filepath.Join(tmpDir, "config")
169+
170+ phase := NewPostInstallPhase(cfg, log, mockFS, mockHTTP, mockSysExec, mockChrExec)
171+
172+ err = phase.PostCheck()
173+ // PostCheck should succeed as it just saves the config
174+ if err != nil {
175+ t.Errorf("PostCheck() error = %v, want nil", err)
176+ }
177+}
178+
179+// TestPostInstallPhaseRollback tests post-install rollback
180+func TestPostInstallPhaseRollback(t *testing.T) {
181+ ctrl := gomock.NewController(t)
182+ defer ctrl.Finish()
183+
184+ mockFS := mocks.NewMockFileSystem(ctrl)
185+ mockHTTP := mocks.NewMockHTTPClient(ctrl)
186+ mockSysExec := mocks.NewMockSystemExecutor(ctrl)
187+ mockChrExec := mocks.NewMockChrootExecutor(ctrl)
188+
189+ tmpDir := t.TempDir()
190+ logPath := filepath.Join(tmpDir, "test.log")
191+ log, err := logger.New(logPath, false)
192+ if err != nil {
193+ t.Fatalf("Failed to create logger: %v", err)
194+ }
195+ defer log.Close()
196+
197+ cfg := config.NewConfig("test")
198+ phase := NewPostInstallPhase(cfg, log, mockFS, mockHTTP, mockSysExec, mockChrExec)
199+
200+ // Rollback should always succeed (it's a cleanup operation)
201+ err = phase.Rollback()
202+ if err != nil {
203+ t.Errorf("Rollback() error = %v, want nil", err)
204+ }
205+}
206+
207+// TestPostInstallPhaseCanSkip tests the CanSkip method
208+func TestPostInstallPhaseCanSkip(t *testing.T) {
209+ ctrl := gomock.NewController(t)
210+ defer ctrl.Finish()
211+
212+ mockFS := mocks.NewMockFileSystem(ctrl)
213+ mockHTTP := mocks.NewMockHTTPClient(ctrl)
214+ mockSysExec := mocks.NewMockSystemExecutor(ctrl)
215+ mockChrExec := mocks.NewMockChrootExecutor(ctrl)
216+
217+ tmpDir := t.TempDir()
218+ logPath := filepath.Join(tmpDir, "test.log")
219+ log, err := logger.New(logPath, false)
220+ if err != nil {
221+ t.Fatalf("Failed to create logger: %v", err)
222+ }
223+ defer log.Close()
224+
225+ cfg := config.NewConfig("test")
226+ phase := NewPostInstallPhase(cfg, log, mockFS, mockHTTP, mockSysExec, mockChrExec)
227+
228+ if phase.CanSkip() {
229+ t.Error("CanSkip() = true, want false")
230+ }
231+}
232+
233+// TestPostInstallPhaseExecute tests the Execute method
234+func TestPostInstallPhaseExecute(t *testing.T) {
235+ ctrl := gomock.NewController(t)
236+ defer ctrl.Finish()
237+
238+ mockFS := mocks.NewMockFileSystem(ctrl)
239+ mockHTTP := mocks.NewMockHTTPClient(ctrl)
240+ mockSysExec := mocks.NewMockSystemExecutor(ctrl)
241+ mockChrExec := mocks.NewMockChrootExecutor(ctrl)
242+
243+ // Allow any file system and executor operations
244+ mockFS.EXPECT().ReadFile(gomock.Any()).Return([]byte{}, nil).AnyTimes()
245+ mockFS.EXPECT().WriteFile(gomock.Any(), gomock.Any(), gomock.Any()).Return(nil).AnyTimes()
246+ mockFS.EXPECT().MkdirAll(gomock.Any(), gomock.Any()).Return(nil).AnyTimes()
247+ mockFS.EXPECT().Create(gomock.Any()).Return(&MockWriteCloser{}, nil).AnyTimes()
248+ mockFS.EXPECT().Stat(gomock.Any()).Return(mockFileInfo{}, nil).AnyTimes()
249+ mockFS.EXPECT().IsNotExist(gomock.Any()).Return(false).AnyTimes()
250+ mockFS.EXPECT().Chmod(gomock.Any(), gomock.Any()).Return(nil).AnyTimes()
251+ mockHTTP.EXPECT().Get(gomock.Any()).Return(nil, fmt.Errorf("skip http")).AnyTimes()
252+ mockChrExec.EXPECT().ChrootExec(gomock.Any(), gomock.Any(), gomock.Any()).Return(nil).AnyTimes()
253+ mockChrExec.EXPECT().ChrootPacman(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).Return(nil).AnyTimes()
254+ mockChrExec.EXPECT().ChrootSystemctl(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).Return(nil).AnyTimes()
255+ mockSysExec.EXPECT().RunSimple(gomock.Any(), gomock.Any()).Return(system.CommandResult{ExitCode: 0}).AnyTimes()
256+
257+ tmpDir := t.TempDir()
258+ logPath := filepath.Join(tmpDir, "test.log")
259+ log, err := logger.New(logPath, false)
260+ if err != nil {
261+ t.Fatalf("Failed to create logger: %v", err)
262+ }
263+ defer log.Close()
264+
265+ cfg := config.NewConfig("test")
266+ cfg.KernelChoice = "linux"
267+ cfg.RawURL = "https://example.com"
268+
269+ phase := NewPostInstallPhase(cfg, log, mockFS, mockHTTP, mockSysExec, mockChrExec)
270+
271+ progressChan := make(chan ProgressUpdate, 100)
272+
273+ // Note: Full Execute testing is complex due to multiple async operations
274+ result := phase.Execute(progressChan)
275+
276+ // Verify result structure is valid
277+ _ = result // If we got here, Execute returned a valid result structure
278+}
279+
280+// TestPostInstallPhaseEncryptionHandling tests encryption-aware operations
281+func TestPostInstallPhaseEncryptionHandling(t *testing.T) {
282+ tests := []struct {
283+ name string
284+ encryptionType string
285+ expectedClosing bool
286+ }{
287+ {
288+ name: "Unencrypted - no closing needed",
289+ encryptionType: config.EncryptionNone,
290+ expectedClosing: false,
291+ },
292+ {
293+ name: "LUKS encrypted - closing needed",
294+ encryptionType: config.EncryptionLUKS,
295+ expectedClosing: true,
296+ },
297+ {
298+ name: "LUKS+LVM - closing needed",
299+ encryptionType: config.EncryptionLUKSLVM,
300+ expectedClosing: true,
301+ },
302+ }
303+
304+ for _, tt := range tests {
305+ t.Run(tt.name, func(t *testing.T) {
306+ ctrl := gomock.NewController(t)
307+ defer ctrl.Finish()
308+
309+ mockFS := mocks.NewMockFileSystem(ctrl)
310+ mockHTTP := mocks.NewMockHTTPClient(ctrl)
311+ mockSysExec := mocks.NewMockSystemExecutor(ctrl)
312+ mockChrExec := mocks.NewMockChrootExecutor(ctrl)
313+
314+ tmpDir := t.TempDir()
315+ logPath := filepath.Join(tmpDir, "test.log")
316+ log, err := logger.New(logPath, false)
317+ if err != nil {
318+ t.Fatalf("Failed to create logger: %v", err)
319+ }
320+ defer log.Close()
321+
322+ cfg := config.NewConfig("test")
323+ cfg.EncryptionType = tt.encryptionType
324+
325+ phase := NewPostInstallPhase(cfg, log, mockFS, mockHTTP, mockSysExec, mockChrExec)
326+
327+ // Verify encryption type is stored correctly
328+ if phase.config.EncryptionType != tt.encryptionType {
329+ t.Errorf("Encryption type = %q, want %q", phase.config.EncryptionType, tt.encryptionType)
330+ }
331+
332+ // Verify closing would be needed based on encryption
333+ needsClosing := tt.encryptionType == config.EncryptionLUKS || tt.encryptionType == config.EncryptionLUKSLVM
334+ if needsClosing != tt.expectedClosing {
335+ t.Errorf("Closing needed = %v, want %v", needsClosing, tt.expectedClosing)
336+ }
337+ })
338+ }
339+}
340+
341+// TestPostInstallPhaseFileSystemOperations tests file system interaction patterns
342+func TestPostInstallPhaseFileSystemOperations(t *testing.T) {
343+ tests := []struct {
344+ name string
345+ operation string // "read", "write", "mkdir", "create"
346+ }{
347+ {
348+ name: "Read pacman.conf",
349+ operation: "read",
350+ },
351+ {
352+ name: "Write pacman.conf",
353+ operation: "write",
354+ },
355+ {
356+ name: "Create hook directory",
357+ operation: "mkdir",
358+ },
359+ {
360+ name: "Create hook file",
361+ operation: "create",
362+ },
363+ }
364+
365+ for _, tt := range tests {
366+ t.Run(tt.name, func(t *testing.T) {
367+ ctrl := gomock.NewController(t)
368+ defer ctrl.Finish()
369+
370+ mockFS := mocks.NewMockFileSystem(ctrl)
371+ mockHTTP := mocks.NewMockHTTPClient(ctrl)
372+ mockSysExec := mocks.NewMockSystemExecutor(ctrl)
373+ mockChrExec := mocks.NewMockChrootExecutor(ctrl)
374+
375+ // Setup basic expectations based on operation
376+ switch tt.operation {
377+ case "read":
378+ mockFS.EXPECT().ReadFile(gomock.Any()).Return([]byte("content"), nil).AnyTimes()
379+ case "write":
380+ mockFS.EXPECT().WriteFile(gomock.Any(), gomock.Any(), gomock.Any()).Return(nil).AnyTimes()
381+ case "mkdir":
382+ mockFS.EXPECT().MkdirAll(gomock.Any(), gomock.Any()).Return(nil).AnyTimes()
383+ case "create":
384+ mockFS.EXPECT().Create(gomock.Any()).Return(&MockWriteCloser{}, nil).AnyTimes()
385+ }
386+
387+ tmpDir := t.TempDir()
388+ logPath := filepath.Join(tmpDir, "test.log")
389+ log, err := logger.New(logPath, false)
390+ if err != nil {
391+ t.Fatalf("Failed to create logger: %v", err)
392+ }
393+ defer log.Close()
394+
395+ cfg := config.NewConfig("test")
396+ phase := NewPostInstallPhase(cfg, log, mockFS, mockHTTP, mockSysExec, mockChrExec)
397+
398+ // Verify phase can be created
399+ if phase == nil {
400+ t.Error("PostInstallPhase is nil")
401+ }
402+ })
403+ }
404+}
405+
406+// TestPostInstallPhaseKernelVerification tests kernel verification logic
407+func TestPostInstallPhaseKernelVerification(t *testing.T) {
408+ tests := []struct {
409+ name string
410+ kernelChoice string
411+ expectedFormat string
412+ }{
413+ {
414+ name: "Linux kernel",
415+ kernelChoice: "linux",
416+ expectedFormat: "vmlinuz-linux",
417+ },
418+ {
419+ name: "Linux LTS kernel",
420+ kernelChoice: "linux-lts",
421+ expectedFormat: "vmlinuz-linux-lts",
422+ },
423+ {
424+ name: "Linux Zen kernel",
425+ kernelChoice: "linux-zen",
426+ expectedFormat: "vmlinuz-linux-zen",
427+ },
428+ }
429+
430+ for _, tt := range tests {
431+ t.Run(tt.name, func(t *testing.T) {
432+ ctrl := gomock.NewController(t)
433+ defer ctrl.Finish()
434+
435+ mockFS := mocks.NewMockFileSystem(ctrl)
436+ mockHTTP := mocks.NewMockHTTPClient(ctrl)
437+ mockSysExec := mocks.NewMockSystemExecutor(ctrl)
438+ mockChrExec := mocks.NewMockChrootExecutor(ctrl)
439+
440+ tmpDir := t.TempDir()
441+ logPath := filepath.Join(tmpDir, "test.log")
442+ log, err := logger.New(logPath, false)
443+ if err != nil {
444+ t.Fatalf("Failed to create logger: %v", err)
445+ }
446+ defer log.Close()
447+
448+ cfg := config.NewConfig("test")
449+ cfg.KernelChoice = tt.kernelChoice
450+
451+ phase := NewPostInstallPhase(cfg, log, mockFS, mockHTTP, mockSysExec, mockChrExec)
452+
453+ // Verify kernel choice is stored
454+ if phase.config.KernelChoice != tt.kernelChoice {
455+ t.Errorf("Kernel choice = %q, want %q", phase.config.KernelChoice, tt.kernelChoice)
456+ }
457+
458+ // Verify expected kernel name format
459+ expectedKernelName := fmt.Sprintf("vmlinuz-%s", tt.kernelChoice)
460+ if !containsSubstring(expectedKernelName, "vmlinuz") {
461+ t.Errorf("Expected kernel name format %q doesn't contain 'vmlinuz'", expectedKernelName)
462+ }
463+ })
464+ }
465+}
M internal/phases/preflight.go +11 -8
@@ -2,30 +2,33 @@ package phases
22
33 import (
44 "fmt"
5- "os"
6- "os/exec"
75 "strings"
86
97 "github.com/bnema/archup/internal/config"
8+ "github.com/bnema/archup/internal/interfaces"
109 "github.com/bnema/archup/internal/logger"
1110 )
1211
1312 // PreflightPhase handles system validation and initial configuration
1413 type PreflightPhase struct {
1514 *BasePhase
15+ fs interfaces.FileSystem
16+ cmdExec interfaces.CommandExecutor
1617 }
1718
1819 // NewPreflightPhase creates a new preflight phase
19-func NewPreflightPhase(cfg *config.Config, log *logger.Logger) *PreflightPhase {
20+func NewPreflightPhase(cfg *config.Config, log *logger.Logger, fs interfaces.FileSystem, cmdExec interfaces.CommandExecutor) *PreflightPhase {
2021 return &PreflightPhase{
2122 BasePhase: NewBasePhase("preflight", "Preflight Checks", cfg, log),
23+ fs: fs,
24+ cmdExec: cmdExec,
2225 }
2326 }
2427
2528 // PreCheck validates system requirements
2629 func (p *PreflightPhase) PreCheck() error {
2730 // Check for Arch Linux
28- if _, err := os.Stat("/etc/arch-release"); os.IsNotExist(err) {
31+ if _, err := p.fs.Stat("/etc/arch-release"); p.fs.IsNotExist(err) {
2932 return fmt.Errorf("must be running on Arch Linux or Arch ISO")
3033 }
3134
@@ -38,13 +41,13 @@ func (p *PreflightPhase) PreCheck() error {
3841 }
3942
4043 for marker, name := range derivatives {
41- if _, err := os.Stat(marker); err == nil {
44+ if _, err := p.fs.Stat(marker); err == nil {
4245 return fmt.Errorf("must be vanilla Arch (detected %s)", name)
4346 }
4447 }
4548
4649 // Check architecture
47- out, err := exec.Command("uname", "-m").Output()
50+ out, err := p.cmdExec.Execute("uname", "-m")
4851 arch := strings.TrimSpace(string(out))
4952
5053 switch {
@@ -55,12 +58,12 @@ func (p *PreflightPhase) PreCheck() error {
5558 }
5659
5760 // Check UEFI
58- if _, err := os.Stat("/sys/firmware/efi/efivars"); os.IsNotExist(err) {
61+ if _, err := p.fs.Stat("/sys/firmware/efi/efivars"); p.fs.IsNotExist(err) {
5962 return fmt.Errorf("must be UEFI boot mode (legacy BIOS not supported)")
6063 }
6164
6265 // Check Secure Boot
63- out, err = exec.Command("bootctl", "status").Output()
66+ out, err = p.cmdExec.Execute("bootctl", "status")
6467 switch {
6568 case err == nil && strings.Contains(string(out), "Secure Boot: enabled"):
6669 return fmt.Errorf("Secure Boot must be disabled")
M internal/phases/repos.go +37 -44
@@ -3,13 +3,12 @@ package phases
33 import (
44 "bufio"
55 "fmt"
6- "os"
76 "path/filepath"
87 "strings"
98
109 "github.com/bnema/archup/internal/config"
10+ "github.com/bnema/archup/internal/interfaces"
1111 "github.com/bnema/archup/internal/logger"
12- "github.com/bnema/archup/internal/system"
1312 )
1413
1514 // Pacman configuration constants
@@ -46,13 +45,19 @@ func IsValidAURHelper(helper string) bool {
4645 // ReposPhase handles repository configuration
4746 type ReposPhase struct {
4847 *BasePhase
48+ fs interfaces.FileSystem
49+ sysExec interfaces.SystemExecutor
50+ chrExec interfaces.ChrootExecutor
4951 chaoticConfig map[string]string
5052 }
5153
5254 // NewReposPhase creates a new repos phase
53-func NewReposPhase(cfg *config.Config, log *logger.Logger) *ReposPhase {
55+func NewReposPhase(cfg *config.Config, log *logger.Logger, fs interfaces.FileSystem, sysExec interfaces.SystemExecutor, chrExec interfaces.ChrootExecutor) *ReposPhase {
5456 return &ReposPhase{
5557 BasePhase: NewBasePhase("repos", "Repository Configuration", cfg, log),
58+ fs: fs,
59+ sysExec: sysExec,
60+ chrExec: chrExec,
5661 chaoticConfig: make(map[string]string),
5762 }
5863 }
@@ -66,15 +71,15 @@ func (p *ReposPhase) PreCheck() error {
6671 }
6772
6873 // Verify /mnt is mounted
69- result := system.RunSimple("mountpoint", "-q", config.PathMnt)
74+ result := p.sysExec.RunSimple("mountpoint", "-q", config.PathMnt)
7075 switch {
7176 case result.ExitCode != 0:
7277 return fmt.Errorf("%s is not mounted", config.PathMnt)
7378 }
7479
7580 // Verify pacman.conf exists
76- switch _, err := os.Stat(config.PathMntEtcPacmanConf); {
77- case os.IsNotExist(err):
81+ switch _, err := p.fs.Stat(config.PathMntEtcPacmanConf); {
82+ case p.fs.IsNotExist(err):
7883 return fmt.Errorf("pacman.conf not found")
7984 }
8085
@@ -154,7 +159,7 @@ func (p *ReposPhase) Execute(progressChan chan<- ProgressUpdate) PhaseResult {
154159 func (p *ReposPhase) syncDatabases(progressChan chan<- ProgressUpdate) error {
155160 p.SendOutput(progressChan, "Syncing package databases...")
156161
157- switch err := system.ChrootExec(p.logger.LogPath(), config.PathMnt, "pacman -Sy --noconfirm"); {
162+ switch err := p.chrExec.ChrootExec(p.logger.LogPath(), config.PathMnt, "pacman -Sy --noconfirm"); {
158163 case err != nil:
159164 return fmt.Errorf("failed to sync databases: %w", err)
160165 }
@@ -168,7 +173,7 @@ func (p *ReposPhase) enableMultilib(progressChan chan<- ProgressUpdate) error {
168173 p.SendOutput(progressChan, "Enabling multilib repository...")
169174
170175 // Read pacman.conf
171- content, err := os.ReadFile(config.PathMntEtcPacmanConf)
176+ content, err := p.fs.ReadFile(config.PathMntEtcPacmanConf)
172177 switch {
173178 case err != nil:
174179 return fmt.Errorf("failed to read pacman.conf: %w", err)
@@ -212,7 +217,7 @@ func (p *ReposPhase) enableMultilib(progressChan chan<- ProgressUpdate) error {
212217 contentStr = strings.Join(lines, "\n")
213218
214219 // Write updated config
215- switch err := os.WriteFile(config.PathMntEtcPacmanConf, []byte(contentStr), 0644); {
220+ switch err := p.fs.WriteFile(config.PathMntEtcPacmanConf, []byte(contentStr), 0644); {
216221 case err != nil:
217222 return fmt.Errorf("failed to write pacman.conf: %w", err)
218223 }
@@ -226,7 +231,7 @@ func (p *ReposPhase) enableMultilib(progressChan chan<- ProgressUpdate) error {
226231 func (p *ReposPhase) loadChaoticConfig() error {
227232 configPath := p.getInstallPath(config.ChaoticConfigFile)
228233
229- file, err := os.Open(configPath)
234+ file, err := p.fs.Open(configPath)
230235 switch {
231236 case err != nil:
232237 return fmt.Errorf("failed to open chaotic config: %w", err)
@@ -280,7 +285,7 @@ func (p *ReposPhase) enableChaoticAUR(progressChan chan<- ProgressUpdate) error
280285
281286 // Import GPG key
282287 recvKeyCmd := fmt.Sprintf("pacman-key --recv-key %s --keyserver %s", keyID, keyserver)
283- switch err := system.ChrootExec(p.logger.LogPath(), config.PathMnt, recvKeyCmd); {
288+ switch err := p.chrExec.ChrootExec(p.logger.LogPath(), config.PathMnt, recvKeyCmd); {
284289 case err != nil:
285290 p.logger.Warn("Failed to receive Chaotic GPG key, skipping Chaotic-AUR", "error", err)
286291 p.SendOutput(progressChan, "[WARN] Chaotic-AUR unavailable (GPG key fetch failed)")
@@ -289,7 +294,7 @@ func (p *ReposPhase) enableChaoticAUR(progressChan chan<- ProgressUpdate) error
289294
290295 // Locally sign the key
291296 lsignKeyCmd := fmt.Sprintf("pacman-key --lsign-key %s", keyID)
292- switch err := system.ChrootExec(p.logger.LogPath(), config.PathMnt, lsignKeyCmd); {
297+ switch err := p.chrExec.ChrootExec(p.logger.LogPath(), config.PathMnt, lsignKeyCmd); {
293298 case err != nil:
294299 p.logger.Warn("Failed to sign Chaotic GPG key, skipping Chaotic-AUR", "error", err)
295300 p.SendOutput(progressChan, "[WARN] Chaotic-AUR unavailable (GPG key signing failed)")
@@ -300,7 +305,7 @@ func (p *ReposPhase) enableChaoticAUR(progressChan chan<- ProgressUpdate) error
300305
301306 // Download and install chaotic-keyring and chaotic-mirrorlist
302307 p.SendOutput(progressChan, "Downloading Chaotic-AUR packages...")
303- switch err := system.DownloadAndInstallPackages(p.logger.LogPath(), config.PathMnt, keyringURL, mirrorlistURL); {
308+ switch err := p.chrExec.DownloadAndInstallPackages(p.logger.LogPath(), config.PathMnt, keyringURL, mirrorlistURL); {
304309 case err != nil:
305310 p.logger.Warn("Chaotic-AUR packages unavailable, skipping", "error", err)
306311 p.SendOutput(progressChan, "[WARN] Chaotic-AUR unavailable, skipping (AUR helper will be built from source)")
@@ -310,7 +315,7 @@ func (p *ReposPhase) enableChaoticAUR(progressChan chan<- ProgressUpdate) error
310315 p.SendOutput(progressChan, "[OK] Chaotic packages installed")
311316
312317 // Add Chaotic-AUR to pacman.conf
313- content, err := os.ReadFile(config.PathMntEtcPacmanConf)
318+ content, err := p.fs.ReadFile(config.PathMntEtcPacmanConf)
314319 switch {
315320 case err != nil:
316321 return fmt.Errorf("failed to read pacman.conf: %w", err)
@@ -327,7 +332,7 @@ func (p *ReposPhase) enableChaoticAUR(progressChan chan<- ProgressUpdate) error
327332 chaoticSection := fmt.Sprintf("\n# Chaotic-AUR repository\n[%s]\nInclude = %s\n", repoName, mirrorlistPath)
328333 contentStr += chaoticSection
329334
330- switch err := os.WriteFile(config.PathMntEtcPacmanConf, []byte(contentStr), 0644); {
335+ switch err := p.fs.WriteFile(config.PathMntEtcPacmanConf, []byte(contentStr), 0644); {
331336 case err != nil:
332337 return fmt.Errorf("failed to write pacman.conf: %w", err)
333338 }
@@ -336,14 +341,14 @@ func (p *ReposPhase) enableChaoticAUR(progressChan chan<- ProgressUpdate) error
336341 }
337342
338343 // Update package databases
339- switch err := system.ChrootExec(p.logger.LogPath(),config.PathMnt, "pacman -Sy --noconfirm"); {
344+ switch err := p.chrExec.ChrootExec(p.logger.LogPath(),config.PathMnt, "pacman -Sy --noconfirm"); {
340345 case err != nil:
341346 return fmt.Errorf("failed to update package databases: %w", err)
342347 }
343348
344349 // Verify Chaotic-AUR is working
345350 verifyCmd := fmt.Sprintf("pacman -Sl %s", repoName)
346- result := system.RunSimple("arch-chroot", config.PathMnt, "sh", "-c", verifyCmd)
351+ result := p.sysExec.RunSimple("arch-chroot", config.PathMnt, "sh", "-c", verifyCmd)
347352 switch {
348353 case result.ExitCode != 0:
349354 p.SendOutput(progressChan, "[WARN] Chaotic-AUR verification failed")
@@ -362,7 +367,7 @@ func (p *ReposPhase) installPackagesIndividually(packages []string, progressChan
362367
363368 for _, pkg := range packages {
364369 individualCmd := fmt.Sprintf("pacman -S --noconfirm --needed %s", pkg)
365- switch err := system.ChrootExec(p.logger.LogPath(), config.PathMnt, individualCmd); {
370+ switch err := p.chrExec.ChrootExec(p.logger.LogPath(), config.PathMnt, individualCmd); {
366371 case err != nil:
367372 p.logger.Warn("Failed to install package", "package", pkg, "error", err)
368373 failedPkgs = append(failedPkgs, pkg)
@@ -393,7 +398,7 @@ func (p *ReposPhase) installExtraPackages(progressChan chan<- ProgressUpdate) er
393398
394399 // Try batch installation first
395400 installCmd := fmt.Sprintf("pacman -S --noconfirm --needed %s", strings.Join(packages, " "))
396- switch err := system.ChrootExec(p.logger.LogPath(), config.PathMnt, installCmd); {
401+ switch err := p.chrExec.ChrootExec(p.logger.LogPath(), config.PathMnt, installCmd); {
397402 case err == nil:
398403 p.logger.Info("Extra packages installed successfully", "count", len(packages))
399404 p.SendOutput(progressChan, fmt.Sprintf("[OK] Installed %d extra packages", len(packages)))
@@ -429,7 +434,7 @@ func (p *ReposPhase) installExtraPackages(progressChan chan<- ProgressUpdate) er
429434 func (p *ReposPhase) loadExtraPackages() ([]string, error) {
430435 packageFile := p.getInstallPath(config.ExtraPackagesFile)
431436
432- file, err := os.Open(packageFile)
437+ file, err := p.fs.Open(packageFile)
433438 switch {
434439 case err != nil:
435440 return nil, fmt.Errorf("failed to open %s: %w", packageFile, err)
@@ -468,43 +473,31 @@ func (p *ReposPhase) installAURHelper(progressChan chan<- ProgressUpdate) error
468473 // Ensure build dependencies are installed
469474 p.SendOutput(progressChan, "Verifying build dependencies...")
470475 depsCmd := "pacman -S --needed --noconfirm base-devel git"
471- switch err := system.ChrootExec(p.logger.LogPath(), config.PathMnt, depsCmd); {
476+ switch err := p.chrExec.ChrootExec(p.logger.LogPath(), config.PathMnt, depsCmd); {
472477 case err != nil:
473478 p.logger.Warn("Failed to install build dependencies", "error", err)
474479 // Don't fail, they might already be installed
475480 }
476481
477- // Build AUR helper from source
482+ // Build AUR helper from source as configured user, then install as root
478483 buildCmd := fmt.Sprintf(`
479- su - %s -c '
480- set -e
481- cd /tmp || exit 1
484+ set -e
482485
483- # Clean up any previous build
484- rm -rf %s
486+ # Build as user (clone, build, etc.)
487+ su - %s -c 'cd /tmp && rm -rf %s && git clone https://aur.archlinux.org/%s.git && cd %s && makepkg -s --noconfirm' || exit 1
485488
486- # Clone AUR repository
487- git clone https://aur.archlinux.org/%s.git || exit 1
488- cd %s || exit 1
489+ # Install built package as root
490+ pacman -U --noconfirm /tmp/%s/*.pkg.tar.zst || exit 1
489491
490- # Build and install
491- makepkg -si --noconfirm || exit 1
492- '
493- `, p.config.Username, p.config.AURHelper, p.config.AURHelper, p.config.AURHelper)
492+ # Clean up build directory
493+ rm -rf /tmp/%s
494+ `, p.config.Username, p.config.AURHelper, p.config.AURHelper, p.config.AURHelper, p.config.AURHelper, p.config.AURHelper)
494495
495- switch err := system.ChrootExec(p.logger.LogPath(), config.PathMnt, buildCmd); {
496+ switch err := p.chrExec.ChrootExec(p.logger.LogPath(), config.PathMnt, buildCmd); {
496497 case err != nil:
497498 return fmt.Errorf("failed to build %s: %w", p.config.AURHelper, err)
498499 }
499500
500- // Cleanup build directory
501- cleanupCmd := fmt.Sprintf("rm -rf /tmp/%s", p.config.AURHelper)
502- switch err := system.ChrootExec(p.logger.LogPath(), config.PathMnt, cleanupCmd); {
503- case err != nil:
504- p.logger.Warn("Failed to cleanup AUR helper build directory", "error", err)
505- // Don't fail, just warn
506- }
507-
508501 p.SendOutput(progressChan, fmt.Sprintf("[OK] %s built and installed successfully", p.config.AURHelper))
509502
510503 return nil
@@ -519,7 +512,7 @@ func (p *ReposPhase) getInstallPath(filename string) string {
519512 // PostCheck validates repository configuration
520513 func (p *ReposPhase) PostCheck() error {
521514 // Verify pacman.conf was modified
522- content, err := os.ReadFile(config.PathMntEtcPacmanConf)
515+ content, err := p.fs.ReadFile(config.PathMntEtcPacmanConf)
523516 switch {
524517 case err != nil:
525518 return fmt.Errorf("failed to read pacman.conf: %w", err)
M internal/phases/repos_test.go +12 -3
@@ -6,7 +6,10 @@ import (
66 "testing"
77
88 "github.com/bnema/archup/internal/config"
9+ "github.com/bnema/archup/internal/interfaces/mocks"
910 "github.com/bnema/archup/internal/logger"
11+ "github.com/bnema/archup/internal/system"
12+ "go.uber.org/mock/gomock"
1013 )
1114
1215 // TestEnableMultilibParsing tests the multilib section and Include line parsing
@@ -536,12 +539,18 @@ func TestReposPhasePreCheckValidation(t *testing.T) {
536539 mockSysExec := mocks.NewMockSystemExecutor(ctrl)
537540 mockChrExec := mocks.NewMockChrootExecutor(ctrl)
538541
542+ // Set up expected mock calls based on whether we'll pass AUR helper validation
543+ // AUR helper validation happens first, so if invalid helper, we won't call RunSimple
544+ if !tt.expectError || (tt.expectError && tt.errorMsg != "invalid AUR helper") {
545+ mockSysExec.EXPECT().RunSimple("mountpoint", "-q", config.PathMnt).Return(system.CommandResult{ExitCode: 0}).Times(1)
546+ mockFS.EXPECT().Stat(config.PathMntEtcPacmanConf).Return(mockFileInfo{}, nil).Times(1)
547+ mockFS.EXPECT().IsNotExist(gomock.Any()).Return(false).Times(1)
548+ }
549+
539550 // Create ReposPhase
540551 phase := NewReposPhase(cfg, log, mockFS, mockSysExec, mockChrExec)
541552
542- // Note: PreCheck also validates /mnt is mounted and pacman.conf exists
543- // In a real test environment, these would fail, so we're only testing
544- // the validation logic here by checking the error message
553+ // Note: PreCheck validates AUR helper first, then /mnt is mounted, and pacman.conf exists
545554 err := phase.PreCheck()
546555
547556 if tt.expectError {
M internal/phases/systemconfig.go +29 -24
@@ -2,30 +2,35 @@ package phases
22
33 import (
44 "fmt"
5- "os"
65 "path/filepath"
76
87 "github.com/bnema/archup/internal/config"
8+ "github.com/bnema/archup/internal/interfaces"
99 "github.com/bnema/archup/internal/logger"
10- "github.com/bnema/archup/internal/system"
1110 )
1211
1312 // ConfigPhase handles system configuration
1413 type ConfigPhase struct {
1514 *BasePhase
15+ fs interfaces.FileSystem
16+ sysExec interfaces.SystemExecutor
17+ chrExec interfaces.ChrootExecutor
1618 }
1719
1820 // NewConfigPhase creates a new config phase
19-func NewConfigPhase(cfg *config.Config, log *logger.Logger) *ConfigPhase {
21+func NewConfigPhase(cfg *config.Config, log *logger.Logger, fs interfaces.FileSystem, sysExec interfaces.SystemExecutor, chrExec interfaces.ChrootExecutor) *ConfigPhase {
2022 return &ConfigPhase{
2123 BasePhase: NewBasePhase("config", "System Configuration", cfg, log),
24+ fs: fs,
25+ sysExec: sysExec,
26+ chrExec: chrExec,
2227 }
2328 }
2429
2530 // PreCheck validates configuration prerequisites
2631 func (p *ConfigPhase) PreCheck() error {
2732 // Verify /mnt is mounted
28- result := system.RunSimple("mountpoint", "-q", config.PathMnt)
33+ result := p.sysExec.RunSimple("mountpoint", "-q", config.PathMnt)
2934 switch {
3035 case result.ExitCode != 0:
3136 return fmt.Errorf("%s is not mounted", config.PathMnt)
@@ -97,13 +102,13 @@ func (p *ConfigPhase) configureSystem(progressChan chan<- ProgressUpdate) error
97102 // Set timezone
98103 timezonePath := filepath.Join(config.PathUsrShareZoneinfo, p.config.Timezone)
99104 tzCmd := fmt.Sprintf("ln -sf %s %s", timezonePath, config.PathEtcLocaltime)
100- switch err := system.ChrootExec(p.logger.LogPath(),config.PathMnt, tzCmd); {
105+ switch err := p.chrExec.ChrootExec(p.logger.LogPath(),config.PathMnt, tzCmd); {
101106 case err != nil:
102107 return fmt.Errorf("failed to set timezone: %w", err)
103108 }
104109
105110 // Set hardware clock
106- switch err := system.ChrootExec(p.logger.LogPath(),config.PathMnt, "hwclock --systohc"); {
111+ switch err := p.chrExec.ChrootExec(p.logger.LogPath(),config.PathMnt, "hwclock --systohc"); {
107112 case err != nil:
108113 return fmt.Errorf("failed to set hardware clock: %w", err)
109114 }
@@ -112,18 +117,18 @@ func (p *ConfigPhase) configureSystem(progressChan chan<- ProgressUpdate) error
112117
113118 // Set locale
114119 localeGen := p.config.Locale + " UTF-8\n"
115- switch err := os.WriteFile(config.PathMntEtcLocaleGen, []byte(localeGen), 0644); {
120+ switch err := p.fs.WriteFile(config.PathMntEtcLocaleGen, []byte(localeGen), 0644); {
116121 case err != nil:
117122 return fmt.Errorf("failed to write locale.gen: %w", err)
118123 }
119124
120- switch err := system.ChrootExec(p.logger.LogPath(),config.PathMnt, "locale-gen"); {
125+ switch err := p.chrExec.ChrootExec(p.logger.LogPath(),config.PathMnt, "locale-gen"); {
121126 case err != nil:
122127 return fmt.Errorf("failed to generate locale: %w", err)
123128 }
124129
125130 localeConf := fmt.Sprintf("LANG=%s\n", p.config.Locale)
126- switch err := os.WriteFile(config.PathMntEtcLocaleConf, []byte(localeConf), 0644); {
131+ switch err := p.fs.WriteFile(config.PathMntEtcLocaleConf, []byte(localeConf), 0644); {
127132 case err != nil:
128133 return fmt.Errorf("failed to write locale.conf: %w", err)
129134 }
@@ -134,7 +139,7 @@ func (p *ConfigPhase) configureSystem(progressChan chan<- ProgressUpdate) error
134139 switch {
135140 case p.config.Keymap != "":
136141 vconsoleConf := fmt.Sprintf("KEYMAP=%s\n", p.config.Keymap)
137- switch err := os.WriteFile(config.PathMntEtcVconsole, []byte(vconsoleConf), 0644); {
142+ switch err := p.fs.WriteFile(config.PathMntEtcVconsole, []byte(vconsoleConf), 0644); {
138143 case err != nil:
139144 return fmt.Errorf("failed to write vconsole.conf: %w", err)
140145 }
@@ -142,7 +147,7 @@ func (p *ConfigPhase) configureSystem(progressChan chan<- ProgressUpdate) error
142147 }
143148
144149 // Set hostname
145- switch err := os.WriteFile(config.PathMntEtcHostname, []byte(p.config.Hostname+"\n"), 0644); {
150+ switch err := p.fs.WriteFile(config.PathMntEtcHostname, []byte(p.config.Hostname+"\n"), 0644); {
146151 case err != nil:
147152 return fmt.Errorf("failed to write hostname: %w", err)
148153 }
@@ -153,7 +158,7 @@ func (p *ConfigPhase) configureSystem(progressChan chan<- ProgressUpdate) error
153158 127.0.1.1 %s.localdomain %s
154159 `, p.config.Hostname, p.config.Hostname)
155160
156- switch err := os.WriteFile(config.PathMntEtcHosts, []byte(hostsContent), 0644); {
161+ switch err := p.fs.WriteFile(config.PathMntEtcHosts, []byte(hostsContent), 0644); {
157162 case err != nil:
158163 return fmt.Errorf("failed to write hosts file: %w", err)
159164 }
@@ -168,7 +173,7 @@ func (p *ConfigPhase) configureNetwork(progressChan chan<- ProgressUpdate) error
168173 p.SendOutput(progressChan, "Configuring network...")
169174
170175 // Enable NetworkManager service
171- switch err := system.ChrootSystemctl(p.logger.LogPath(),config.PathMnt, "enable", config.ServiceNetworkManager); {
176+ switch err := p.chrExec.ChrootSystemctl(p.logger.LogPath(),config.PathMnt, "enable", config.ServiceNetworkManager); {
172177 case err != nil:
173178 return fmt.Errorf("failed to enable NetworkManager: %w", err)
174179 }
@@ -184,20 +189,20 @@ func (p *ConfigPhase) createUser(progressChan chan<- ProgressUpdate) error {
184189
185190 // Create user with home directory
186191 userAddCmd := fmt.Sprintf("useradd -m -G %s -s %s %s", config.GroupWheel, config.ShellBash, p.config.Username)
187- switch err := system.ChrootExec(p.logger.LogPath(),config.PathMnt, userAddCmd); {
192+ switch err := p.chrExec.ChrootExec(p.logger.LogPath(),config.PathMnt, userAddCmd); {
188193 case err != nil:
189194 return fmt.Errorf("failed to create user: %w", err)
190195 }
191196
192197 // Set user password using stdin (secure - not visible in process list)
193198 userPassInput := fmt.Sprintf("%s:%s", p.config.Username, p.config.UserPassword)
194- switch err := system.ChrootExecWithStdin(p.logger.LogPath(), config.PathMnt, "chpasswd", userPassInput); {
199+ switch err := p.chrExec.ChrootExecWithStdin(p.logger.LogPath(), config.PathMnt, "chpasswd", userPassInput); {
195200 case err != nil:
196201 return fmt.Errorf("failed to set user password: %w", err)
197202 }
198203
199204 // Enable sudo for wheel group (passwordless for convenience)
200- switch err := os.WriteFile(config.PathMntEtcSudoersD, []byte(config.SudoersWheelContent), config.SudoersWheelPerms); {
205+ switch err := p.fs.WriteFile(config.PathMntEtcSudoersD, []byte(config.SudoersWheelContent), config.SudoersWheelPerms); {
201206 case err != nil:
202207 return fmt.Errorf("failed to write sudoers config: %w", err)
203208 }
@@ -214,14 +219,14 @@ func (p *ConfigPhase) configureZram(progressChan chan<- ProgressUpdate) error {
214219
215220 // Create zram-generator config
216221 zramConfigPath := filepath.Join(config.PathMntEtcSystemd, config.FileZramGenerator)
217- switch err := os.WriteFile(zramConfigPath, []byte(config.ZramConfigContent), 0644); {
222+ switch err := p.fs.WriteFile(zramConfigPath, []byte(config.ZramConfigContent), 0644); {
218223 case err != nil:
219224 return fmt.Errorf("failed to write zram-generator.conf: %w", err)
220225 }
221226
222227 // Create sysctl config for zram optimization
223228 sysctlConfigPath := filepath.Join(config.PathMntEtcSysctlD, config.FileSysctlZramParams)
224- switch err := os.WriteFile(sysctlConfigPath, []byte(config.ZramSysctlContent), 0644); {
229+ switch err := p.fs.WriteFile(sysctlConfigPath, []byte(config.ZramSysctlContent), 0644); {
225230 case err != nil:
226231 return fmt.Errorf("failed to write sysctl zram config: %w", err)
227232 }
@@ -234,21 +239,21 @@ func (p *ConfigPhase) configureZram(progressChan chan<- ProgressUpdate) error {
234239 // PostCheck validates configuration
235240 func (p *ConfigPhase) PostCheck() error {
236241 // Verify hostname was set
237- switch _, err := os.Stat(config.PathMntEtcHostname); {
238- case os.IsNotExist(err):
242+ switch _, err := p.fs.Stat(config.PathMntEtcHostname); {
243+ case p.fs.IsNotExist(err):
239244 return fmt.Errorf("hostname file was not created")
240245 }
241246
242247 // Verify locale was set
243- switch _, err := os.Stat(config.PathMntEtcLocaleConf); {
244- case os.IsNotExist(err):
248+ switch _, err := p.fs.Stat(config.PathMntEtcLocaleConf); {
249+ case p.fs.IsNotExist(err):
245250 return fmt.Errorf("locale.conf was not created")
246251 }
247252
248253 // Verify zram config exists
249254 zramConfigPath := filepath.Join(config.PathMntEtcSystemd, config.FileZramGenerator)
250- switch _, err := os.Stat(zramConfigPath); {
251- case os.IsNotExist(err):
255+ switch _, err := p.fs.Stat(zramConfigPath); {
256+ case p.fs.IsNotExist(err):
252257 return fmt.Errorf("zram-generator.conf was not created")
253258 }
254259
A internal/ui/assets/assets.go +6 -0
@@ -0,0 +1,6 @@
1+package assets
2+
3+import _ "embed"
4+
5+//go:embed logo.txt
6+var LogoASCII string
A internal/ui/assets/logo.txt +6 -0
@@ -0,0 +1,6 @@
1+ ░█████╗░██████╗░░█████╗░██╗░░██╗██╗░░░██╗██████╗░
2+ ██╔══██╗██╔══██╗██╔══██╗██║░░██║██║░░░██║██╔══██╗
3+ ███████║██████╔╝██║░░╚═╝███████║██║░░░██║██████╔╝
4+ ██╔══██║██╔══██╗██║░░██╗██╔══██║██║░░░██║██╔═══╝░
5+ ██║░░██║██║░░██║╚█████╔╝██║░░██║╚██████╔╝██║░░░░░
6+ ╚═╝░░╚═╝╚═╝░░╚═╝░╚════╝░╚═╝░░╚═╝░╚═════╝░╚═╝░░░░░
D internal/ui/logo.go +0 -109
@@ -1,109 +0,0 @@
1-package ui
2-
3-import (
4- "fmt"
5- "strings"
6-
7- "github.com/charmbracelet/lipgloss"
8- "github.com/bnema/archup/internal/ui/styles"
9-)
10-
11-const (
12- // Logo ASCII art (from logo.txt)
13- logoASCII = ` ░█████╗░██████╗░░█████╗░██╗░░██╗██╗░░░██╗██████╗░
14- ██╔══██╗██╔══██╗██╔══██╗██║░░██║██║░░░██║██╔══██╗
15- ███████║██████╔╝██║░░╚═╝███████║██║░░░██║██████╔╝
16- ██╔══██║██╔══██╗██║░░██╗██╔══██║██║░░░██║██╔═══╝░
17- ██║░░██║██║░░██║╚█████╔╝██║░░██║╚██████╔╝██║░░░░░
18- ╚═╝░░╚═╝╚═╝░░╚═╝░╚════╝░╚═╝░░╚═╝░╚═════╝░╚═╝░░░░░`
19-)
20-
21-// Logo represents the ArchUp logo display
22-type Logo struct {
23- width int
24- height int
25- version string
26-}
27-
28-// NewLogo creates a new Logo instance
29-// version should be injected via ldflags: -ldflags "-X main.version=v1.0.0"
30-func NewLogo(version string) *Logo {
31- lines := strings.Split(logoASCII, "\n")
32- width := 0
33- for _, line := range lines {
34- if len(line) > width {
35- width = len(line)
36- }
37- }
38-
39- if version == "" {
40- version = "dev"
41- }
42-
43- return &Logo{
44- width: width,
45- height: len(lines),
46- version: version,
47- }
48-}
49-
50-// Render renders the logo in full blue
51-func (l *Logo) Render() string {
52- lines := strings.Split(logoASCII, "\n")
53- var rendered strings.Builder
54-
55- for _, line := range lines {
56- if len(line) == 0 {
57- rendered.WriteString("\n")
58- continue
59- }
60-
61- // Render entire logo in blue
62- rendered.WriteString(styles.LogoCyanStyle.Render(line))
63- rendered.WriteString("\n")
64- }
65-
66- // Add version below logo
67- rendered.WriteString("\n")
68- versionLine := " " + l.version
69- rendered.WriteString(styles.LogoCyanStyle.Render(versionLine))
70- rendered.WriteString("\n")
71-
72- return rendered.String()
73-}
74-
75-// RenderCentered renders the logo centered using lipgloss alignment
76-func (l *Logo) RenderCentered(termWidth int) string {
77- return l.Render()
78-}
79-
80-// Width returns the logo width
81-func (l *Logo) Width() int {
82- return l.width
83-}
84-
85-// Height returns the logo height (including version line)
86-func (l *Logo) Height() int {
87- return l.height + 2 // +2 for blank line and version
88-}
89-
90-// ClearScreen clears the screen and displays the logo at the top
91-func ClearScreen(termWidth int, version string) string {
92- logo := NewLogo(version)
93- return fmt.Sprintf("\033[H\033[2J\n%s", logo.RenderCentered(termWidth))
94-}
95-
96-// RenderWithMessage renders the logo with a message below it
97-func RenderWithMessage(termWidth int, version, message string) string {
98- logo := NewLogo(version)
99- logoStr := logo.RenderCentered(termWidth)
100-
101- // Center the message
102- paddingLeft := (termWidth - lipgloss.Width(message)) / 2
103- if paddingLeft < 0 {
104- paddingLeft = 0
105- }
106- padding := strings.Repeat(" ", paddingLeft)
107-
108- return fmt.Sprintf("%s\n%s%s\n", logoStr, padding, message)
109-}
M internal/ui/styles/theme.go +3 -0
@@ -163,6 +163,9 @@ var (
163163 LogoWhiteStyle = lipgloss.NewStyle().
164164 Foreground(ColorPureWhite)
165165
166+ LogoBlueStyle = lipgloss.NewStyle().
167+ Foreground(ColorSkyBlue)
168+
166169 // Progress/Spinner styles
167170 SpinnerStyle = lipgloss.NewStyle().
168171 Foreground(ColorBrightCyan)
M internal/ui/views/helpers.go +20 -15
@@ -1,22 +1,15 @@
11 package views
22
33 import (
4- "fmt"
54 "strings"
65
6+ "github.com/bnema/archup/internal/ui/assets"
77 "github.com/bnema/archup/internal/ui/styles"
88 "github.com/charmbracelet/lipgloss"
99 )
1010
1111 const (
1212 WelcomeTagline = "A minimal, slightly opinionated Arch Linux installer."
13-
14- logoASCII = ` ░█████╗░██████╗░░█████╗░██╗░░██╗██╗░░░██╗██████╗░
15- ██╔══██╗██╔══██╗██╔══██╗██║░░██║██║░░░██║██╔══██╗
16- ███████║██████╔╝██║░░╚═╝███████║██║░░░██║██████╔╝
17- ██╔══██║██╔══██╗██║░░██╗██╔══██║██║░░░██║██╔═══╝░
18- ██║░░██║██║░░██║╚█████╔╝██║░░██║╚██████╔╝██║░░░░░
19- ╚═╝░░╚═╝╚═╝░░╚═╝░╚════╝░╚═╝░░╚═╝░╚═════╝░╚═╝░░░░░`
2013 )
2114
2215 // Logo represents the ArchUp logo display
@@ -28,7 +21,7 @@ type Logo struct {
2821
2922 // NewLogo creates a new Logo instance
3023 func NewLogo(version string) *Logo {
31- lines := strings.Split(logoASCII, "\n")
24+ lines := strings.Split(assets.LogoASCII, "\n")
3225 width := 0
3326 for _, line := range lines {
3427 if len(line) > width {
@@ -52,9 +45,9 @@ func (l *Logo) RenderCentered(termWidth int) string {
5245 return l.Render()
5346 }
5447
55-// Render renders the logo with theme colors
48+// Render renders the logo with character-level styling
5649 func (l *Logo) Render() string {
57- lines := strings.Split(logoASCII, "\n")
50+ lines := strings.Split(assets.LogoASCII, "\n")
5851 var rendered strings.Builder
5952
6053 for _, line := range lines {
@@ -63,14 +56,26 @@ func (l *Logo) Render() string {
6356 continue
6457 }
6558
66- // Apply bright cyan color to the logo
67- rendered.WriteString(styles.LogoCyanStyle.Render(line))
59+ // Apply conditional styling based on character
60+ var styledLine strings.Builder
61+ for _, ch := range line {
62+ if string(ch) == "░" {
63+ // Use darker blue for border character
64+ styledLine.WriteString(styles.LogoBlueStyle.Render(string(ch)))
65+ } else {
66+ // Use bright cyan for main logo
67+ styledLine.WriteString(styles.LogoCyanStyle.Render(string(ch)))
68+ }
69+ }
70+ rendered.WriteString(styledLine.String())
6871 rendered.WriteString("\n")
6972 }
7073
74+ // Add version below logo
75+ rendered.WriteString("\n")
76+ versionLine := " " + l.version
77+ rendered.WriteString(styles.HelpStyle.Render(versionLine))
7178 rendered.WriteString("\n")
72- // Apply dimmed style to version
73- rendered.WriteString(styles.HelpStyle.Render(fmt.Sprintf(" %s\n", l.version)))
7479
7580 return rendered.String()
7681 }

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