Skip to content

Commit 33b06c7

Browse files
authored
Merge pull request #39 from harakeishi/feat/expand-integration-tests
テスト拡充 & CLI出力のミニマル化(Presenterパターン導入)
2 parents 6c7c17c + cb1f853 commit 33b06c7

File tree

12 files changed

+1065
-73
lines changed

12 files changed

+1065
-73
lines changed

cmd/root.go

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import (
1111

1212
"github.com/harakeishi/gopose/internal/config"
1313
"github.com/harakeishi/gopose/internal/logger"
14+
"github.com/harakeishi/gopose/internal/presenter"
1415
"github.com/harakeishi/gopose/pkg/types"
1516
)
1617

@@ -116,3 +117,8 @@ func getLogger(cfg types.Config) (logger.Logger, error) {
116117
factory := logger.NewStructuredLoggerFactory(detail)
117118
return factory.Create(cfg.GetLog())
118119
}
120+
121+
// getPresenter はプレゼンターを取得します。
122+
func getPresenter() presenter.Presenter {
123+
return presenter.NewTablePresenter(os.Stdout)
124+
}

cmd/up.go

Lines changed: 25 additions & 52 deletions
Original file line numberDiff line numberDiff line change
@@ -139,28 +139,27 @@ var upCmd = &cobra.Command{
139139
RunE: func(cmd *cobra.Command, args []string) error {
140140
ctx := cmd.Context()
141141
cfg := getConfig()
142+
pres := getPresenter()
142143

143144
logger, err := getLogger(cfg)
144145
if err != nil {
145146
return fmt.Errorf("ロガーの初期化に失敗しました: %w", err)
146147
}
147148

148-
// ポート設定の作成(設定ファイルの値をベースに、CLIオプションで上書き)
149149
portConfig, err := createPortConfig(portRange, cfg.GetPort())
150150
if err != nil {
151151
return fmt.Errorf("ポート範囲の解析に失敗しました: %w", err)
152152
}
153153

154-
// -p オプションが指定されていない場合は、ワークツリー名をプロジェクト名として自動設定
155154
if composeProjectName == "" && os.Getenv("COMPOSE_PROJECT_NAME") == "" {
156155
if pn, err := detectWorktreeProjectName(); err == nil && pn != "" {
157156
composeProjectName = pn
158-
logger.Info(ctx, "ワークツリー名をプロジェクト名として使用",
157+
logger.Debug(ctx, "ワークツリー名をプロジェクト名として使用",
159158
types.Field{Key: "project_name", Value: composeProjectName})
160159
}
161160
}
162161

163-
logger.Info(ctx, "ポート衝突解決を開始",
162+
logger.Debug(ctx, "ポート衝突解決を開始",
164163
types.Field{Key: "dry_run", Value: dryRun},
165164
types.Field{Key: "compose_file", Value: filePath},
166165
types.Field{Key: "output_file", Value: outputFile},
@@ -171,7 +170,7 @@ var upCmd = &cobra.Command{
171170

172171
// Docker Composeファイルの自動検出(指定されていない場合)
173172
if filePath == "" || filePath == "compose.yml" {
174-
wd, err := os.Getwd()
173+
wd, err := os.Getwd()
175174
if err != nil {
176175
return fmt.Errorf("作業ディレクトリの取得に失敗: %w", err)
177176
}
@@ -182,7 +181,7 @@ var upCmd = &cobra.Command{
182181
return fmt.Errorf("docker composeファイルの自動検出に失敗: %w", err)
183182
}
184183
filePath = detectedFile
185-
logger.Info(ctx, "Docker Composeファイルを自動検出", types.Field{Key: "file", Value: filePath})
184+
logger.Debug(ctx, "Docker Composeファイルを自動検出", types.Field{Key: "file", Value: filePath})
186185
}
187186

188187
// Docker Composeファイルの解析
@@ -192,6 +191,8 @@ var upCmd = &cobra.Command{
192191
return fmt.Errorf("docker composeファイルの解析に失敗: %w", err)
193192
}
194193

194+
pres.Progress("Scanning...")
195+
195196
// 統一的な衝突検知の実行
196197
portDetector := scanner.NewNetstatPortDetector(logger)
197198
portAllocator := scanner.NewPortAllocatorImpl(portDetector, logger)
@@ -205,15 +206,15 @@ var upCmd = &cobra.Command{
205206

206207
// 衝突がない場合
207208
if !conflictInfo.HasConflicts() {
208-
logger.Info(ctx, "衝突は検出されませんでした")
209-
if skipComposeUp {
210-
logger.Warn(ctx, "--skip-compose-upオプションは不要になりました。デフォルトでdocker compose upは実行されません。")
211-
}
209+
pres.PortConflicts(nil)
210+
pres.NetworkConflicts(nil)
211+
pres.Result("No conflicts detected.")
212212
return nil
213213
}
214214

215-
// 衝突結果の表示
216-
logger.Info(ctx, "衝突検知完了",
215+
pres.Progress("Resolving...")
216+
217+
logger.Debug(ctx, "衝突検知完了",
217218
types.Field{Key: "port_conflicts", Value: len(conflictInfo.PortConflicts)},
218219
types.Field{Key: "network_conflicts", Value: len(conflictInfo.NetworkConflicts)})
219220

@@ -234,34 +235,17 @@ var upCmd = &cobra.Command{
234235
return fmt.Errorf("衝突解決に失敗: %w", err)
235236
}
236237

237-
// 解決結果の表示
238-
for _, conflict := range conflictInfo.PortConflicts {
239-
if conflict.Resolution != nil {
240-
logger.Info(ctx, "ポート解決",
241-
types.Field{Key: "service", Value: conflict.ServiceName},
242-
types.Field{Key: "from", Value: conflict.Port},
243-
types.Field{Key: "to", Value: conflict.Resolution.ResolvedPort},
244-
types.Field{Key: "reason", Value: conflict.Resolution.Reason})
245-
}
246-
}
247-
248-
for _, conflict := range conflictInfo.NetworkConflicts {
249-
if conflict.Resolution != nil {
250-
logger.Info(ctx, "ネットワーク解決",
251-
types.Field{Key: "network", Value: conflict.NetworkName},
252-
types.Field{Key: "from", Value: conflict.OriginalSubnet},
253-
types.Field{Key: "to", Value: conflict.Resolution.ResolvedSubnet},
254-
types.Field{Key: "reason", Value: conflict.Resolution.Reason})
255-
}
256-
}
238+
// 衝突回避結果のテーブル表示
239+
pres.PortConflicts(conflictInfo.PortConflicts)
240+
pres.NetworkConflicts(conflictInfo.NetworkConflicts)
257241

258242
// 統一的なOverride.ymlの生成
259243
override, err := unifiedGenerator.GenerateFromConflicts(ctx, config, conflictInfo)
260244
if err != nil {
261245
return fmt.Errorf("overrideファイルの生成に失敗: %w", err)
262246
}
263247

264-
// プロジェクト名をoverrideに設定(Docker Composeコマンドの統一のため)
248+
// プロジェクト名をoverrideに設定
265249
if composeProjectName != "" {
266250
override.Name = composeProjectName
267251
logger.Debug(ctx, "Override.ymlにプロジェクト名を設定",
@@ -279,29 +263,18 @@ var upCmd = &cobra.Command{
279263
outputFile = "compose.override.yml"
280264
}
281265

282-
// ドライランモードでない場合のみファイル書き込み
283-
if !dryRun {
284-
// Override.ymlファイルの書き込み
285-
if err := overrideGenerator.WriteOverrideFile(ctx, override, outputFile); err != nil {
286-
return fmt.Errorf("overrideファイルの書き込みに失敗: %w", err)
287-
}
288-
289-
logger.Info(ctx, "Override.ymlファイルが生成されました",
290-
types.Field{Key: "output_file", Value: outputFile})
291-
} else {
292-
logger.Info(ctx, "ドライランモードのため、ファイルは生成されません")
293-
}
294-
295-
// Docker Composeの実行(--skip-compose-upが指定された場合は後方互換のための警告を表示)
296-
if skipComposeUp {
297-
logger.Warn(ctx, "--skip-compose-upオプションは不要になりました。デフォルトでdocker compose upは実行されません。")
266+
// ドライランモードの場合
267+
if dryRun {
268+
pres.Result("Dry run: no files written.")
269+
return nil
298270
}
299271

300-
// デフォルトではDocker Composeを実行しない
301-
if !dryRun {
302-
logger.Info(ctx, "override.ymlの生成が完了しました。docker compose upを実行する場合は、手動で実行してください。")
272+
// Override.ymlファイルの書き込み
273+
if err := overrideGenerator.WriteOverrideFile(ctx, override, outputFile); err != nil {
274+
return fmt.Errorf("overrideファイルの書き込みに失敗: %w", err)
303275
}
304276

277+
pres.Result("Generated: " + outputFile)
305278
return nil
306279
},
307280
}
Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,115 @@
1+
# CLI Output Styling Design
2+
3+
## Summary
4+
5+
gopose の CLI 出力をミニマル&クリーンにリデザインする。不要な途中経過を削り、衝突回避結果をテーブル形式で見やすく表示する。
6+
7+
## Requirements
8+
9+
- 途中経過は簡潔な1行ずつ(`Scanning...`, `Resolving...`
10+
- 衝突回避結果はテーブル形式で表示
11+
- 衝突なし時はテーブルヘッダー + `(none)` で明示
12+
- カラー出力は使わない(プレーンテキストのみ)
13+
- `--detail` 時の構造化ログ出力は従来通り維持
14+
15+
## Output Format
16+
17+
### Normal (conflicts found)
18+
19+
```
20+
Scanning...
21+
Resolving...
22+
23+
Port Conflicts:
24+
SERVICE FROM TO
25+
web 3000 8001
26+
api 5432 5433
27+
28+
Network Conflicts:
29+
NETWORK FROM TO
30+
default 172.20.0.0/24 10.20.0.0/24
31+
32+
Generated: compose.override.yml
33+
```
34+
35+
### No conflicts
36+
37+
```
38+
Scanning...
39+
40+
Port Conflicts:
41+
(none)
42+
43+
Network Conflicts:
44+
(none)
45+
46+
No conflicts detected.
47+
```
48+
49+
### Dry run
50+
51+
```
52+
Scanning...
53+
Resolving...
54+
55+
Port Conflicts:
56+
SERVICE FROM TO
57+
web 3000 8001
58+
59+
Network Conflicts:
60+
(none)
61+
62+
Dry run: no files written.
63+
```
64+
65+
## Architecture
66+
67+
### New Component: `internal/presenter/`
68+
69+
```
70+
internal/presenter/
71+
interfaces.go -- Presenter interface
72+
table.go -- TablePresenter implementation
73+
table_test.go -- Tests
74+
```
75+
76+
### SOLID Compliance
77+
78+
- **S (Single Responsibility)**: Presenter handles user-facing output formatting only. Logger handles structured logging. cmd/up.go handles workflow control.
79+
- **O (Open/Closed)**: Presenter is an interface. New output formats (JSON, color) can be added as new implementations without modifying existing code.
80+
- **L (Liskov Substitution)**: All Presenter implementations (TablePresenter, NopPresenter) satisfy the same contract.
81+
- **I (Interface Segregation)**: Presenter is a small, focused interface separate from Logger.
82+
- **D (Dependency Inversion)**: cmd/up.go depends on the Presenter interface, not concrete implementations. TablePresenter depends on io.Writer for testability.
83+
84+
### Interface Design
85+
86+
```go
87+
// internal/presenter/interfaces.go
88+
type Presenter interface {
89+
Progress(message string)
90+
PortConflicts(conflicts []types.PortConflictInfo)
91+
NetworkConflicts(conflicts []types.NetworkConflictInfo)
92+
Result(message string)
93+
}
94+
```
95+
96+
### Implementation
97+
98+
```go
99+
// internal/presenter/table.go
100+
type TablePresenter struct {
101+
w io.Writer
102+
}
103+
104+
func NewTablePresenter(w io.Writer) *TablePresenter
105+
```
106+
107+
- Uses `text/tabwriter` (stdlib) for column alignment
108+
- No external dependencies
109+
- `io.Writer` injection for testability
110+
111+
### Changes to cmd/up.go
112+
113+
- When `detailed=false`: use Presenter for all user-facing output, suppress logger output for those messages
114+
- When `detailed=true`: use Logger for structured logging as before (Presenter still used for table output)
115+
- Remove redundant log messages (e.g. "ポート衝突解決を開始", "override.ymlの生成が完了しました")

0 commit comments

Comments
 (0)