1package cli
2
3import (
4 "bytes"
5 "context"
6 "errors"
7 "regexp"
8 "strings"
9 "testing"
10 "time"
11
12 "github.com/stretchr/testify/assert"
13 "github.com/stretchr/testify/require"
14
15 "github.com/bnema/gordon/internal/adapters/dto"
16 "github.com/bnema/gordon/internal/domain"
17)
18
19type imagesClientMock struct {
20 listImagesResp []dto.Image
21 listImagesErr error
22 pruneResp *dto.ImagePruneResponse
23 pruneErr error
24
25 listImagesCalls int
26 pruneCalls int
27 lastPruneOpts dto.ImagePruneRequest
28}
29
30func (f *imagesClientMock) ListImages(_ context.Context) ([]dto.Image, error) {
31 f.listImagesCalls++
32 if f.listImagesErr != nil {
33 return nil, f.listImagesErr
34 }
35 return f.listImagesResp, nil
36}
37
38func (f *imagesClientMock) PruneImages(_ context.Context, req dto.ImagePruneRequest) (*dto.ImagePruneResponse, error) {
39 f.pruneCalls++
40 f.lastPruneOpts = req
41 if f.pruneErr != nil {
42 return nil, f.pruneErr
43 }
44 return f.pruneResp, nil
45}
46
47// ---------------------------------------------------------------------------
48// images list tests (unchanged)
49// ---------------------------------------------------------------------------
50
51func TestRunImagesList_PrintsRowsAndSummary(t *testing.T) {
52 createdAt := time.Date(2026, 2, 8, 12, 0, 0, 0, time.UTC)
53 client := &imagesClientMock{
54 listImagesResp: []dto.Image{
55 {Repository: "registry.example.com/app", Tag: "latest", Size: 12_000_000, Created: createdAt, ID: "sha256:1111", Dangling: false},
56 {Repository: "<none>", Tag: "<none>", Size: 512_000, Created: createdAt, ID: "sha256:2222", Dangling: true},
57 },
58 }
59
60 var out bytes.Buffer
61 err := runImagesList(context.Background(), client, &out, false)
62 require.NoError(t, err)
63
64 text := out.String()
65 assert.Contains(t, text, "REPOSITORY")
66 assert.Contains(t, text, "registry.example.com/app")
67 assert.Contains(t, text, "latest")
68 assert.Contains(t, text, "Total images: 2 (dangling: 1)")
69 assert.Equal(t, 1, client.listImagesCalls)
70}
71
72func TestRunImagesList_HeaderOrderAndSummaryLine(t *testing.T) {
73 createdAt := time.Date(2026, 2, 8, 12, 0, 0, 0, time.UTC)
74 client := &imagesClientMock{
75 listImagesResp: []dto.Image{
76 {Repository: "registry.example.com/app", Tag: "latest", Size: 12_000_000, Created: createdAt, ID: "sha256:1111", Dangling: false},
77 },
78 }
79
80 var out bytes.Buffer
81 err := runImagesList(context.Background(), client, &out, false)
82 require.NoError(t, err)
83
84 text := stripANSI(out.String())
85 header := findHeaderLine(text)
86 assert.NotEmpty(t, header)
87 assert.Less(t, strings.Index(header, "REPOSITORY"), strings.Index(header, "TAG"))
88 assert.Less(t, strings.Index(header, "TAG"), strings.Index(header, "SIZE"))
89 assert.Less(t, strings.Index(header, "SIZE"), strings.Index(header, "CREATED"))
90 assert.Less(t, strings.Index(header, "CREATED"), strings.Index(header, "IMAGE_ID"))
91 assert.Less(t, strings.Index(header, "IMAGE_ID"), strings.Index(header, "DANGLING"))
92 assert.Contains(t, text, "Total images: 1 (dangling: 0)")
93}
94
95func TestRunImagesList_TruncatesLongValuesAndKeepsBoundedRowShape(t *testing.T) {
96 createdAt := time.Date(2026, 2, 8, 12, 0, 0, 0, time.UTC)
97 longRepository := "registry.example.com/team/this-is-a-very-long-service-name-with-extra-segments/and/more/segments/than/anyone/should/reasonably/use"
98 longImageID := "sha256:0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef-extra-suffix-for-overflow"
99 client := &imagesClientMock{
100 listImagesResp: []dto.Image{
101 {Repository: longRepository, Tag: "latest", Size: 12_000_000, Created: createdAt, ID: longImageID, Dangling: false},
102 },
103 }
104
105 var out bytes.Buffer
106 err := runImagesList(context.Background(), client, &out, false)
107 require.NoError(t, err)
108
109 text := stripANSI(out.String())
110 header := findHeaderLine(text)
111 assert.NotEmpty(t, header)
112 assert.Less(t, strings.Index(header, "REPOSITORY"), strings.Index(header, "TAG"))
113 assert.Less(t, strings.Index(header, "TAG"), strings.Index(header, "SIZE"))
114 assert.Less(t, strings.Index(header, "SIZE"), strings.Index(header, "CREATED"))
115 assert.Less(t, strings.Index(header, "CREATED"), strings.Index(header, "IMAGE_ID"))
116 assert.Less(t, strings.Index(header, "IMAGE_ID"), strings.Index(header, "DANGLING"))
117
118 row := findImageListRowLine(text, "latest")
119 assert.NotEmpty(t, row)
120
121 cols := splitTableColumns(row)
122 require.Len(t, cols, 6)
123
124 repositoryField := cols[0]
125 imageIDField := cols[4]
126
127 assert.NotEqual(t, longRepository, repositoryField)
128 assert.NotEqual(t, longImageID, imageIDField)
129 assert.True(t, strings.HasSuffix(repositoryField, "..."), "repository should be truncated with ellipsis")
130 assert.True(t, strings.HasSuffix(imageIDField, "..."), "image id should be truncated with ellipsis")
131 assert.True(t, strings.HasPrefix(longRepository, strings.TrimSuffix(repositoryField, "...")))
132 assert.True(t, strings.HasPrefix(longImageID, strings.TrimSuffix(imageIDField, "...")))
133
134 assert.Contains(t, text, "Total images: 1 (dangling: 0)")
135}
136
137func TestRunImagesList_LongDigestLikeValuesAndEmptyFormattedFields(t *testing.T) {
138 longRepository := "registry.example.com/very/long/repository/path/with/deep/nesting/and-digest-like-ref@sha256:0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef-extra"
139 longTag := "release-2026-02-12-build-metadata-with-a-very-very-long-tag-name"
140 longImageID := "sha256:abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789"
141 client := &imagesClientMock{
142 listImagesResp: []dto.Image{
143 {Repository: longRepository, Tag: longTag, Size: 0, Created: time.Time{}, ID: longImageID, Dangling: true},
144 },
145 }
146
147 var out bytes.Buffer
148 err := runImagesList(context.Background(), client, &out, false)
149 require.NoError(t, err)
150
151 text := stripANSI(out.String())
152 rows := findImageListRows(text)
153 require.Len(t, rows, 1, "expected a single, no-wrap data row")
154
155 cols := rows[0]
156 require.Len(t, cols, 6)
157
158 repositoryField := cols[0]
159 tagField := cols[1]
160 sizeField := cols[2]
161 createdField := cols[3]
162 imageIDField := cols[4]
163
164 assert.True(t, strings.HasSuffix(repositoryField, "..."), "repository should be truncated with ellipsis")
165 assert.True(t, strings.HasSuffix(tagField, "..."), "tag should be truncated with ellipsis")
166 assert.True(t, strings.HasSuffix(imageIDField, "..."), "image id should be truncated with ellipsis")
167 assert.True(t, strings.HasPrefix(longRepository, strings.TrimSuffix(repositoryField, "...")))
168 assert.True(t, strings.HasPrefix(longTag, strings.TrimSuffix(tagField, "...")))
169 assert.True(t, strings.HasPrefix(longImageID, strings.TrimSuffix(imageIDField, "...")))
170
171 assert.Equal(t, "0 B", sizeField)
172 assert.Equal(t, "-", createdField)
173 assert.Contains(t, text, "Total images: 1 (dangling: 1)")
174}
175
176func TestRunImagesList_EmptyOutput(t *testing.T) {
177 client := &imagesClientMock{listImagesResp: []dto.Image{}}
178
179 var out bytes.Buffer
180 err := runImagesList(context.Background(), client, &out, false)
181 require.NoError(t, err)
182
183 text := out.String()
184 assert.Contains(t, text, "No images found")
185 assert.Contains(t, text, "Total images: 0")
186 assert.Equal(t, 1, client.listImagesCalls)
187}
188
189// ---------------------------------------------------------------------------
190// images prune: default scope behavior
191// ---------------------------------------------------------------------------
192
193func TestRunImagesPrune_DefaultScopeIsBothDanglingAndRegistry(t *testing.T) {
194 // No scope flags → both scopes enabled, keep-releases defaults to domain default.
195 client := &imagesClientMock{
196 pruneResp: &dto.ImagePruneResponse{
197 Runtime: dto.RuntimePruneResult{DeletedCount: 1, SpaceReclaimed: 512},
198 Registry: dto.RegistryPruneResult{TagsRemoved: 2, BlobsRemoved: 1, SpaceReclaimed: 1024},
199 },
200 }
201
202 var out bytes.Buffer
203 opts := imagesPruneOptions{KeepReleases: domain.DefaultImagePruneKeepLast, NoConfirm: true}
204 err := runImagesPrune(context.Background(), client, opts, &out)
205 require.NoError(t, err)
206
207 assert.Equal(t, 1, client.pruneCalls)
208 require.NotNil(t, client.lastPruneOpts.KeepLast)
209 assert.Equal(t, domain.DefaultImagePruneKeepLast, *client.lastPruneOpts.KeepLast)
210 require.NotNil(t, client.lastPruneOpts.PruneDangling)
211 assert.True(t, *client.lastPruneOpts.PruneDangling)
212 require.NotNil(t, client.lastPruneOpts.PruneRegistry)
213 assert.True(t, *client.lastPruneOpts.PruneRegistry)
214
215 text := out.String()
216 assert.Contains(t, text, "Runtime: deleted=1")
217 assert.Contains(t, text, "Registry: tags_removed=2")
218}
219
220func TestRunImagesPrune_DanglingOnlyScopeSkipsRegistry(t *testing.T) {
221 client := &imagesClientMock{
222 pruneResp: &dto.ImagePruneResponse{
223 Runtime: dto.RuntimePruneResult{DeletedCount: 3, SpaceReclaimed: 2048},
224 },
225 }
226
227 var out bytes.Buffer
228 opts := imagesPruneOptions{Dangling: true, NoConfirm: true}
229 err := runImagesPrune(context.Background(), client, opts, &out)
230 require.NoError(t, err)
231
232 assert.Equal(t, 1, client.pruneCalls)
233 require.NotNil(t, client.lastPruneOpts.PruneDangling)
234 assert.True(t, *client.lastPruneOpts.PruneDangling)
235 require.NotNil(t, client.lastPruneOpts.PruneRegistry)
236 assert.False(t, *client.lastPruneOpts.PruneRegistry)
237
238 text := out.String()
239 assert.Contains(t, text, "Runtime: deleted=3")
240 assert.Contains(t, text, "Registry cleanup skipped")
241}
242
243func TestRunImagesPrune_RegistryOnlyScopeSkipsRuntime(t *testing.T) {
244 client := &imagesClientMock{
245 pruneResp: &dto.ImagePruneResponse{
246 Registry: dto.RegistryPruneResult{TagsRemoved: 4, BlobsRemoved: 2, SpaceReclaimed: 4096},
247 },
248 }
249
250 var out bytes.Buffer
251 opts := imagesPruneOptions{Registry: true, KeepReleases: 5, NoConfirm: true}
252 err := runImagesPrune(context.Background(), client, opts, &out)
253 require.NoError(t, err)
254
255 assert.Equal(t, 1, client.pruneCalls)
256 require.NotNil(t, client.lastPruneOpts.PruneDangling)
257 assert.False(t, *client.lastPruneOpts.PruneDangling)
258 require.NotNil(t, client.lastPruneOpts.PruneRegistry)
259 assert.True(t, *client.lastPruneOpts.PruneRegistry)
260 require.NotNil(t, client.lastPruneOpts.KeepLast)
261 assert.Equal(t, 5, *client.lastPruneOpts.KeepLast)
262
263 text := out.String()
264 assert.Contains(t, text, "Runtime cleanup skipped")
265 assert.Contains(t, text, "Registry: tags_removed=4")
266}
267
268func TestRunImagesPrune_BothScopeFlagsExplicit(t *testing.T) {
269 client := &imagesClientMock{
270 pruneResp: &dto.ImagePruneResponse{
271 Runtime: dto.RuntimePruneResult{DeletedCount: 1, SpaceReclaimed: 100},
272 Registry: dto.RegistryPruneResult{TagsRemoved: 1, BlobsRemoved: 0, SpaceReclaimed: 200},
273 },
274 }
275
276 var out bytes.Buffer
277 opts := imagesPruneOptions{Dangling: true, Registry: true, KeepReleases: 2, NoConfirm: true}
278 err := runImagesPrune(context.Background(), client, opts, &out)
279 require.NoError(t, err)
280
281 assert.Equal(t, 1, client.pruneCalls)
282 require.NotNil(t, client.lastPruneOpts.PruneDangling)
283 assert.True(t, *client.lastPruneOpts.PruneDangling)
284 require.NotNil(t, client.lastPruneOpts.PruneRegistry)
285 assert.True(t, *client.lastPruneOpts.PruneRegistry)
286 require.NotNil(t, client.lastPruneOpts.KeepLast)
287 assert.Equal(t, 2, *client.lastPruneOpts.KeepLast)
288
289 text := out.String()
290 assert.Contains(t, text, "Runtime: deleted=1")
291 assert.Contains(t, text, "Registry: tags_removed=1")
292}
293
294// ---------------------------------------------------------------------------
295// images prune: dry-run with scope flags
296// ---------------------------------------------------------------------------
297
298func TestRunImagesPrune_DryRunBothScopes(t *testing.T) {
299 client := &imagesClientMock{
300 listImagesResp: []dto.Image{{Dangling: true}, {Dangling: false}, {Dangling: true}},
301 }
302
303 var out bytes.Buffer
304 opts := imagesPruneOptions{DryRun: true, KeepReleases: 3}
305 err := runImagesPrune(context.Background(), client, opts, &out)
306 require.NoError(t, err)
307
308 text := out.String()
309 assert.Contains(t, text, "Dry run")
310 assert.Contains(t, text, "would prune 2 dangling runtime images")
311 assert.Contains(t, text, "would keep latest + 3 previous tags")
312 assert.Equal(t, 1, client.listImagesCalls)
313 assert.Equal(t, 0, client.pruneCalls)
314}
315
316func TestRunImagesPrune_DryRunDanglingOnlyScope(t *testing.T) {
317 client := &imagesClientMock{
318 listImagesResp: []dto.Image{{Dangling: true}},
319 }
320
321 var out bytes.Buffer
322 opts := imagesPruneOptions{DryRun: true, Dangling: true}
323 err := runImagesPrune(context.Background(), client, opts, &out)
324 require.NoError(t, err)
325
326 text := out.String()
327 assert.Contains(t, text, "would prune 1 dangling runtime images")
328 assert.Contains(t, text, "Registry cleanup skipped")
329 assert.Equal(t, 0, client.pruneCalls)
330}
331
332func TestRunImagesPrune_DryRunRegistryOnlyScope(t *testing.T) {
333 client := &imagesClientMock{
334 listImagesResp: []dto.Image{{Dangling: true}, {Dangling: false}},
335 }
336
337 var out bytes.Buffer
338 opts := imagesPruneOptions{DryRun: true, Registry: true, KeepReleases: 5}
339 err := runImagesPrune(context.Background(), client, opts, &out)
340 require.NoError(t, err)
341
342 text := out.String()
343 assert.Contains(t, text, "Runtime cleanup skipped")
344 assert.Contains(t, text, "would keep latest + 5 previous tags")
345 assert.Equal(t, 0, client.pruneCalls)
346}
347
348// ---------------------------------------------------------------------------
349// images prune: keep-releases semantics
350// ---------------------------------------------------------------------------
351
352func TestRunImagesPrune_KeepReleasesControlsRetention(t *testing.T) {
353 client := &imagesClientMock{
354 pruneResp: &dto.ImagePruneResponse{
355 Runtime: dto.RuntimePruneResult{DeletedCount: 0},
356 Registry: dto.RegistryPruneResult{TagsRemoved: 7, BlobsRemoved: 3, SpaceReclaimed: 9000},
357 },
358 }
359
360 var out bytes.Buffer
361 opts := imagesPruneOptions{KeepReleases: 10, NoConfirm: true}
362 err := runImagesPrune(context.Background(), client, opts, &out)
363 require.NoError(t, err)
364
365 require.NotNil(t, client.lastPruneOpts.KeepLast)
366 assert.Equal(t, 10, *client.lastPruneOpts.KeepLast)
367}
368
369func TestRunImagesPrune_RejectsNegativeKeepReleases(t *testing.T) {
370 err := runImagesPrune(context.Background(), &imagesClientMock{}, imagesPruneOptions{KeepReleases: -1}, &bytes.Buffer{})
371 require.Error(t, err)
372 assert.Contains(t, err.Error(), "--keep-releases must be >= 0")
373}
374
375// ---------------------------------------------------------------------------
376// images prune: confirmation behavior
377// ---------------------------------------------------------------------------
378
379func TestRunImagesPrune_NonDryRunCallsConfirm(t *testing.T) {
380 confirmCalled := false
381 origConfirm := pruneConfirmFunc
382 pruneConfirmFunc = func(_ string) (bool, error) {
383 confirmCalled = true
384 return true, nil
385 }
386 t.Cleanup(func() { pruneConfirmFunc = origConfirm })
387
388 client := &imagesClientMock{
389 pruneResp: &dto.ImagePruneResponse{
390 Runtime: dto.RuntimePruneResult{DeletedCount: 1},
391 Registry: dto.RegistryPruneResult{TagsRemoved: 1},
392 },
393 }
394
395 var out bytes.Buffer
396 opts := imagesPruneOptions{KeepReleases: domain.DefaultImagePruneKeepLast}
397 err := runImagesPrune(context.Background(), client, opts, &out)
398 require.NoError(t, err)
399
400 assert.True(t, confirmCalled, "confirmation should have been called")
401 assert.Equal(t, 1, client.pruneCalls)
402}
403
404func TestRunImagesPrune_ConfirmPromptIncludesScopePreviewCounts(t *testing.T) {
405 created := time.Date(2026, 2, 13, 7, 0, 0, 0, time.UTC)
406 var prompt string
407 origConfirm := pruneConfirmFunc
408 pruneConfirmFunc = func(value string) (bool, error) {
409 prompt = value
410 return false, nil
411 }
412 t.Cleanup(func() { pruneConfirmFunc = origConfirm })
413
414 client := &imagesClientMock{
415 listImagesResp: []dto.Image{
416 {Repository: "registry.example.com/app", Tag: "latest", Created: created},
417 {Repository: "registry.example.com/app", Tag: "v3", Created: created.Add(-1 * time.Hour)},
418 {Repository: "registry.example.com/app", Tag: "v2", Created: created.Add(-2 * time.Hour)},
419 {Repository: "registry.example.com/app", Tag: "v1", Created: created.Add(-3 * time.Hour)},
420 {Repository: "<none>", Tag: "<none>", Dangling: true},
421 },
422 }
423
424 var out bytes.Buffer
425 opts := imagesPruneOptions{KeepReleases: 2}
426 err := runImagesPrune(context.Background(), client, opts, &out)
427 require.NoError(t, err)
428
429 assert.Contains(t, prompt, "Runtime: would prune 1 dangling runtime images")
430 assert.Contains(t, prompt, "Registry: would prune 1 tags")
431 assert.Equal(t, 1, client.listImagesCalls)
432 assert.Equal(t, 0, client.pruneCalls)
433}
434
435func TestRunImagesPrune_ConfirmRejectCancelsOperation(t *testing.T) {
436 origConfirm := pruneConfirmFunc
437 pruneConfirmFunc = func(_ string) (bool, error) {
438 return false, nil
439 }
440 t.Cleanup(func() { pruneConfirmFunc = origConfirm })
441
442 client := &imagesClientMock{pruneResp: &dto.ImagePruneResponse{}}
443
444 var out bytes.Buffer
445 opts := imagesPruneOptions{KeepReleases: domain.DefaultImagePruneKeepLast}
446 err := runImagesPrune(context.Background(), client, opts, &out)
447 require.NoError(t, err)
448
449 assert.Equal(t, 0, client.pruneCalls, "prune should not be called after rejection")
450 assert.Contains(t, out.String(), "cancelled")
451}
452
453func TestRunImagesPrune_NoConfirmSkipsPrompt(t *testing.T) {
454 confirmCalled := false
455 origConfirm := pruneConfirmFunc
456 pruneConfirmFunc = func(_ string) (bool, error) {
457 confirmCalled = true
458 return true, nil
459 }
460 t.Cleanup(func() { pruneConfirmFunc = origConfirm })
461
462 client := &imagesClientMock{
463 pruneResp: &dto.ImagePruneResponse{
464 Runtime: dto.RuntimePruneResult{DeletedCount: 1},
465 },
466 }
467
468 var out bytes.Buffer
469 opts := imagesPruneOptions{KeepReleases: domain.DefaultImagePruneKeepLast, NoConfirm: true}
470 err := runImagesPrune(context.Background(), client, opts, &out)
471 require.NoError(t, err)
472
473 assert.False(t, confirmCalled, "confirmation should not be called with --no-confirm")
474 assert.Equal(t, 1, client.pruneCalls)
475}
476
477func TestRunImagesPrune_DryRunNeverPrompts(t *testing.T) {
478 confirmCalled := false
479 origConfirm := pruneConfirmFunc
480 pruneConfirmFunc = func(_ string) (bool, error) {
481 confirmCalled = true
482 return true, nil
483 }
484 t.Cleanup(func() { pruneConfirmFunc = origConfirm })
485
486 client := &imagesClientMock{
487 listImagesResp: []dto.Image{{Dangling: true}},
488 }
489
490 var out bytes.Buffer
491 opts := imagesPruneOptions{DryRun: true, KeepReleases: 3}
492 err := runImagesPrune(context.Background(), client, opts, &out)
493 require.NoError(t, err)
494
495 assert.False(t, confirmCalled, "confirmation should not be called during dry-run")
496}
497
498// ---------------------------------------------------------------------------
499// images prune: error handling
500// ---------------------------------------------------------------------------
501
502func TestRunImagesPrune_ReturnsRemoteErrors(t *testing.T) {
503 origConfirm := pruneConfirmFunc
504 pruneConfirmFunc = func(_ string) (bool, error) { return true, nil }
505 t.Cleanup(func() { pruneConfirmFunc = origConfirm })
506
507 client := &imagesClientMock{pruneErr: errors.New("request failed")}
508
509 err := runImagesPrune(context.Background(), client, imagesPruneOptions{KeepReleases: 2}, &bytes.Buffer{})
510 require.Error(t, err)
511 assert.Contains(t, err.Error(), "failed to prune images")
512}
513
514func TestRunImagesPrune_RejectsEmptyResponse(t *testing.T) {
515 origConfirm := pruneConfirmFunc
516 pruneConfirmFunc = func(_ string) (bool, error) { return true, nil }
517 t.Cleanup(func() { pruneConfirmFunc = origConfirm })
518
519 client := &imagesClientMock{pruneResp: nil}
520 err := runImagesPrune(context.Background(), client, imagesPruneOptions{KeepReleases: 1}, &bytes.Buffer{})
521 require.Error(t, err)
522 assert.Contains(t, err.Error(), "empty response")
523}
524
525// ---------------------------------------------------------------------------
526// test helpers
527// ---------------------------------------------------------------------------
528
529func stripANSI(value string) string {
530 ansiRegex := regexp.MustCompile(`\x1b\[[0-9;]*m`)
531 return ansiRegex.ReplaceAllString(value, "")
532}
533
534func findHeaderLine(text string) string {
535 for line := range strings.SplitSeq(text, "\n") {
536 if strings.Contains(line, "REPOSITORY") && strings.Contains(line, "DANGLING") {
537 return line
538 }
539 }
540
541 return ""
542}
543
544func findImageListRowLine(text string, tag string) string {
545 for line := range strings.SplitSeq(text, "\n") {
546 trimmed := strings.TrimSpace(line)
547 if trimmed == "" {
548 continue
549 }
550 if strings.HasPrefix(trimmed, "Images") || strings.HasPrefix(trimmed, "Total images:") {
551 continue
552 }
553
554 cols := splitTableColumns(line)
555 if len(cols) == 6 && cols[1] == tag {
556 return line
557 }
558 }
559
560 return ""
561}
562
563func findImageListRows(text string) [][]string {
564 rows := make([][]string, 0)
565 for line := range strings.SplitSeq(text, "\n") {
566 trimmed := strings.TrimSpace(line)
567 if trimmed == "" {
568 continue
569 }
570 if strings.HasPrefix(trimmed, "Images") || strings.HasPrefix(trimmed, "Total images:") {
571 continue
572 }
573
574 cols := splitTableColumns(line)
575 if len(cols) == 6 && cols[0] != "REPOSITORY" {
576 rows = append(rows, cols)
577 }
578 }
579
580 return rows
581}
582
583func splitTableColumns(line string) []string {
584 if strings.Contains(line, "│") {
585 parts := strings.Split(line, "│")
586 cols := make([]string, 0, len(parts))
587 for _, part := range parts {
588 cell := strings.TrimSpace(part)
589 if cell != "" {
590 cols = append(cols, cell)
591 }
592 }
593 return cols
594 }
595
596 return regexp.MustCompile(`\s{2,}`).Split(strings.TrimSpace(line), -1)
597}