wslc: match docker output format for image list (#41369)

wslc: match docker output format for image list - Emit docker-shaped fields (Containers, CreatedAt/CreatedSince, Digest, ID, Repository/Tag, Size, SharedSize/UniqueSize) via shared ImageOutputInformation so table and json output can't drift; "<none>" for missing repo/tag, "N/A" for values wslc doesn't track. - Add FormatDockerSize (base-1000, 3 significant digits) and FormatDockerTimestamp (Go time.Time layout) to common string helpers; localize relative time strings. - Add container counts to WSLCImageInformation, computed in the service and only requested when json output will show them; cap image ID with WSLC_MAX_IMAGE_ID_LENGTH. - Tests: string, relative time, and image list e2e coverage. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

ggarzia-MSFT committed Aug 18, 2026 at 14:08 UTC 3849bb245f3b893622546c6c6f482ef3cf7bca2a
18 files changed +560 -74
localization/strings/en-US/Resources.resw
+44
@@ -3698,6 +3698,50 @@ On first run, creates the file with all settings commented out at their defaults
3698 <data name="WSLCCLI_TableHeaderPids" xml:space="preserve">
3699 <value>PIDS</value>
3700 </data>
3701 + <data name="WSLCCLI_RelativeTimeLessThanASecond" xml:space="preserve">
3702 + <value>Less than a second ago</value>
3703 + <comment>Describes how long ago something happened, shown in the CREATED column of 'wslc container list' and 'wslc image list'.</comment>
3704 + </data>
3705 + <data name="WSLCCLI_RelativeTimeOneSecond" xml:space="preserve">
3706 + <value>1 second ago</value>
3707 + <comment>Describes how long ago something happened, shown in the CREATED column of 'wslc container list' and 'wslc image list'.</comment>
3708 + </data>
3709 + <data name="WSLCCLI_RelativeTimeSeconds" xml:space="preserve">
3710 + <value>{} seconds ago</value>
3711 + <comment>{FixedPlaceholder="{}"}Command line arguments, file names and string inserts should not be translated. The placeholder is a number of seconds. Describes how long ago something happened, shown in the CREATED column of 'wslc container list' and 'wslc image list'.</comment>
3712 + </data>
3713 + <data name="WSLCCLI_RelativeTimeAboutAMinute" xml:space="preserve">
3714 + <value>About a minute ago</value>
3715 + <comment>Describes how long ago something happened, shown in the CREATED column of 'wslc container list' and 'wslc image list'.</comment>
3716 + </data>
3717 + <data name="WSLCCLI_RelativeTimeMinutes" xml:space="preserve">
3718 + <value>{} minutes ago</value>
3719 + <comment>{FixedPlaceholder="{}"}Command line arguments, file names and string inserts should not be translated. The placeholder is a number of minutes. Describes how long ago something happened, shown in the CREATED column of 'wslc container list' and 'wslc image list'.</comment>
3720 + </data>
3721 + <data name="WSLCCLI_RelativeTimeAboutAnHour" xml:space="preserve">
3722 + <value>About an hour ago</value>
3723 + <comment>Describes how long ago something happened, shown in the CREATED column of 'wslc container list' and 'wslc image list'.</comment>
3724 + </data>
3725 + <data name="WSLCCLI_RelativeTimeHours" xml:space="preserve">
3726 + <value>{} hours ago</value>
3727 + <comment>{FixedPlaceholder="{}"}Command line arguments, file names and string inserts should not be translated. The placeholder is a number of hours. Describes how long ago something happened, shown in the CREATED column of 'wslc container list' and 'wslc image list'.</comment>
3728 + </data>
3729 + <data name="WSLCCLI_RelativeTimeDays" xml:space="preserve">
3730 + <value>{} days ago</value>
3731 + <comment>{FixedPlaceholder="{}"}Command line arguments, file names and string inserts should not be translated. The placeholder is a number of days. Describes how long ago something happened, shown in the CREATED column of 'wslc container list' and 'wslc image list'.</comment>
3732 + </data>
3733 + <data name="WSLCCLI_RelativeTimeWeeks" xml:space="preserve">
3734 + <value>{} weeks ago</value>
3735 + <comment>{FixedPlaceholder="{}"}Command line arguments, file names and string inserts should not be translated. The placeholder is a number of weeks. Describes how long ago something happened, shown in the CREATED column of 'wslc container list' and 'wslc image list'.</comment>
3736 + </data>
3737 + <data name="WSLCCLI_RelativeTimeMonths" xml:space="preserve">
3738 + <value>{} months ago</value>
3739 + <comment>{FixedPlaceholder="{}"}Command line arguments, file names and string inserts should not be translated. The placeholder is a number of months. Describes how long ago something happened, shown in the CREATED column of 'wslc container list' and 'wslc image list'.</comment>
3740 + </data>
3741 + <data name="WSLCCLI_RelativeTimeYears" xml:space="preserve">
3742 + <value>{} years ago</value>
3743 + <comment>{FixedPlaceholder="{}"}Command line arguments, file names and string inserts should not be translated. The placeholder is a number of years. Describes how long ago something happened, shown in the CREATED column of 'wslc container list' and 'wslc image list'.</comment>
3744 + </data>
3745 <data name="WSLCUserSettings_Warning_InvalidValue" xml:space="preserve">
3746 <value>Warning: Invalid value for setting '{}' in {}:{}.</value>
3747 <comment>{FixedPlaceholder="{}"}Command line arguments, file names and string inserts should not be translated</comment>
src/windows/common/string.cpp
+33
@@ -400,6 +400,21 @@ std::wstring wsl::windows::common::string::FormatBytes(uint64_t Bytes)
400 return FormatStorageSize(Bytes, StorageSizeUnit::Decimal, 2, true);
401 }
402
403 +std::wstring wsl::windows::common::string::FormatDockerSize(uint64_t Bytes)
404 +{
405 + constexpr std::wstring_view c_units[] = {L"B", L"kB", L"MB", L"GB", L"TB", L"PB", L"EB", L"ZB", L"YB"};
406 +
407 + auto value = static_cast<double>(Bytes);
408 + size_t unitIndex = 0;
409 + while (value >= 1000.0 && unitIndex + 1 < std::size(c_units))
410 + {
411 + value /= 1000.0;
412 + unitIndex++;
413 + }
414 +
415 + return std::format(L"{:.3g}{}", value, c_units[unitIndex]);
416 +}
417 +
418 std::wstring wsl::windows::common::string::TruncateId(_In_ std::wstring_view id, bool shortenLength)
419 {
420 return TruncateIdImpl(id, shortenLength);
@@ -409,3 +424,21 @@ std::string wsl::windows::common::string::TruncateId(_In_ std::string_view id, b
424 {
425 return TruncateIdImpl(id, shortenLength);
426 }
427 +
428 +std::string wsl::windows::common::string::FormatDockerTimestamp(LONGLONG timestamp)
429 +{
430 + const auto time =
431 + std::chrono::floor<std::chrono::seconds>(std::chrono::system_clock::from_time_t(static_cast<std::time_t>(timestamp)));
432 +
433 + try
434 + {
435 + const auto* zone = std::chrono::current_zone();
436 + return std::format("{:%F %T %z} {}", std::chrono::zoned_time{zone, time}, zone->get_info(time).abbrev);
437 + }
438 + catch (...)
439 + {
440 + // The time zone database is unavailable, so report UTC rather than failing the caller.
441 + LOG_CAUGHT_EXCEPTION();
442 + return std::format("{:%F %T} +0000 UTC", time);
443 + }
444 +}
src/windows/common/string.hpp
+8
@@ -35,6 +35,10 @@ std::wstring FormatStorageSize(uint64_t Bytes, StorageSizeUnit Unit, uint32_t De
35
36 std::wstring FormatBytes(uint64_t Bytes);
37
38 +// Formats a size the way docker reports image sizes: base 1000, three significant digits and no
39 +// space (119856765 -> "120MB").
40 +std::wstring FormatDockerSize(uint64_t Bytes);
41 +
42 std::vector<std::string> InitializeStringSet(_In_count_(BufferSize) LPCSTR Buffer, _In_ SIZE_T BufferSize);
43
44 bool IsPathComponentEqual(const std::wstring_view String1, const std::wstring_view String2);
@@ -63,6 +67,10 @@ std::string WideToMultiByte(_In_ std::wstring_view Source);
67 std::wstring TruncateId(_In_ std::wstring_view id, bool shortenLength = true);
68 std::string TruncateId(_In_ std::string_view id, bool shortenLength = true);
69
70 +// Formats a unix timestamp the way docker does, matching Go's time.Time.String() layout. Falls back
71 +// to UTC when the time zone database is unavailable.
72 +std::string FormatDockerTimestamp(LONGLONG timestamp);
73 +
74 // Template implementation for TruncateId to avoid code duplication.
75 // Algorithm inspired from Moby for consistency in presentation of shortened IDs.
76 // Always strips the algorithm prefix (e.g., "sha256:") if present, and optionally shortens to 12 characters.
src/windows/inc/docker_schema.h
+2 -1
@@ -697,6 +697,7 @@ struct ContainerInfo
697 std::string Id;
698 std::vector<std::string> Names;
699 std::string Image;
700 + std::string ImageID;
701 std::map<std::string, std::string> Labels;
702 std::vector<Port> Ports;
703 std::vector<Mount> Mounts;
@@ -705,7 +706,7 @@ struct ContainerInfo
706 HostConfig HostConfig;
707 NetworkSettings NetworkSettings;
708
708 - NLOHMANN_DEFINE_TYPE_INTRUSIVE_WITH_DEFAULT(ContainerInfo, Id, Names, Image, Labels, Ports, Mounts, State, Created, HostConfig, NetworkSettings);
709 + NLOHMANN_DEFINE_TYPE_INTRUSIVE_WITH_DEFAULT(ContainerInfo, Id, Names, Image, ImageID, Labels, Ports, Mounts, State, Created, HostConfig, NetworkSettings);
710 };
711
712 struct BuildKitVertex
src/windows/service/inc/WSLCShared.idl
+2 -1
@@ -74,9 +74,10 @@ typedef enum _WSLCListImagesFlags
74 WSLCListImagesFlagsNone = 0,
75 WSLCListImagesFlagsAll = 1, // Show all images (default hides intermediate images)
76 WSLCListImagesFlagsDigests = 2, // Include digest information
77 + WSLCListImagesFlagsContainerCounts = 4, // Populate WSLCImageInformation::Containers
78 } WSLCListImagesFlags;
79
79 -cpp_quote("#define WSLCListImagesFlagsValid (WSLCListImagesFlagsAll | WSLCListImagesFlagsDigests)")
80 +cpp_quote("#define WSLCListImagesFlagsValid (WSLCListImagesFlagsAll | WSLCListImagesFlagsDigests | WSLCListImagesFlagsContainerCounts)")
81
82 cpp_quote("DEFINE_ENUM_FLAG_OPERATORS(WSLCListImagesFlags);")
83
src/windows/service/inc/wslc.idl
+4 -1
@@ -30,6 +30,7 @@ cpp_quote("#endif")
30 #define WSLC_MAX_VOLUME_NAME_LENGTH 255
31 #define WSLC_MAX_VOLUME_DRIVER_LENGTH 255
32 #define WSLC_MAX_NETWORK_NAME_LENGTH 255
33 +#define WSLC_MAX_IMAGE_ID_LENGTH 255
34 #define WSLC_CONTAINER_ID_LENGTH 64
35 #define WSLC_MAX_BINDING_ADDRESS_LENGTH 45
36 #define WSLC_EPHEMERAL_PORT 0
@@ -40,6 +41,7 @@ cpp_quote("#define WSLC_MAX_IMAGE_NAME_LENGTH 255")
41 cpp_quote("#define WSLC_MAX_VOLUME_NAME_LENGTH 255")
42 cpp_quote("#define WSLC_MAX_VOLUME_DRIVER_LENGTH 255")
43 cpp_quote("#define WSLC_MAX_NETWORK_NAME_LENGTH 255")
44 +cpp_quote("#define WSLC_MAX_IMAGE_ID_LENGTH 255")
45 cpp_quote("#define WSLC_CONTAINER_ID_LENGTH 64")
46 cpp_quote("#define WSLC_MAX_BINDING_ADDRESS_LENGTH 45")
47 cpp_quote("#define WSLC_MAX_SAVE_IMAGES_COUNT 256")
@@ -139,11 +141,12 @@ interface IWSLCPluginNotifier : IUnknown
141 typedef struct _WSLCImageInformation
142 {
143 char Image[WSLC_MAX_IMAGE_NAME_LENGTH + 1];
142 - char Hash[256];
144 + char Hash[WSLC_MAX_IMAGE_ID_LENGTH + 1];
145 char Digest[256];
146 LONGLONG Size; // Matches Docker's int64 image size
147 LONGLONG Created; // Unix timestamp
148 char ParentId[256];
149 + LONGLONG Containers; // Number of containers created from the image, or -1 if it wasn't requested
150 } WSLCImageInformation;
151
152 typedef struct _KeyValuePairInformation
src/windows/wslc/services/ContainerService.cpp
+40 -24
@@ -294,49 +294,65 @@ std::wstring ContainerService::FormatRelativeTime(ULONGLONG timestamp)
294 return L"";
295 }
296
297 + return FormatElapsedSeconds(static_cast<LONGLONG>(std::time(nullptr)) - static_cast<LONGLONG>(timestamp));
298 +}
299 +
300 +std::wstring ContainerService::FormatElapsedSeconds(LONGLONG elapsedSeconds)
301 +{
302 constexpr LONGLONG SecondsPerMinute = std::chrono::duration_cast<std::chrono::seconds>(1min).count();
303 constexpr LONGLONG SecondsPerHour = std::chrono::duration_cast<std::chrono::seconds>(1h).count();
299 - constexpr LONGLONG SecondsPerDay = std::chrono::duration_cast<std::chrono::seconds>(24h).count();
300 - constexpr LONGLONG SecondsPerWeek = SecondsPerDay * 7;
301 - constexpr LONGLONG SecondsPerMonth = SecondsPerDay * 30;
302 - constexpr LONGLONG SecondsPerYear = SecondsPerDay * 365;
304 + constexpr LONGLONG HoursPerDay = 24;
305 + constexpr LONGLONG MinutesPerHour = 60;
306 +
307 + const auto elapsed = std::max<LONGLONG>(elapsedSeconds, 0);
308
304 - auto elapsed = static_cast<LONGLONG>(std::time(nullptr)) - static_cast<LONGLONG>(timestamp);
305 - if (elapsed < 0)
309 + if (elapsed < 1)
310 {
307 - elapsed = 0;
311 + return Localization::WSLCCLI_RelativeTimeLessThanASecond();
312 + }
313 + else if (elapsed == 1)
314 + {
315 + return Localization::WSLCCLI_RelativeTimeOneSecond();
316 + }
317 + else if (elapsed < SecondsPerMinute)
318 + {
319 + return Localization::WSLCCLI_RelativeTimeSeconds(elapsed);
320 }
321
310 - auto pluralize = [](LONGLONG count, const wchar_t* singular, const wchar_t* plural) {
311 - return std::format(L"{} {} ago", count, (count == 1 ? singular : plural));
312 - };
313 -
314 - if (elapsed < SecondsPerMinute)
322 + const auto minutes = elapsed / SecondsPerMinute;
323 + if (minutes == 1)
324 {
316 - return pluralize(elapsed, L"second", L"seconds");
325 + return Localization::WSLCCLI_RelativeTimeAboutAMinute();
326 }
318 - else if (elapsed < SecondsPerHour)
327 + else if (minutes < MinutesPerHour)
328 + {
329 + return Localization::WSLCCLI_RelativeTimeMinutes(minutes);
330 + }
331 +
332 + // Rounded to the nearest hour rather than truncated.
333 + const auto hours = (elapsed + (SecondsPerHour / 2)) / SecondsPerHour;
334 + if (hours == 1)
335 {
320 - return pluralize(elapsed / SecondsPerMinute, L"minute", L"minutes");
336 + return Localization::WSLCCLI_RelativeTimeAboutAnHour();
337 }
322 - else if (elapsed < SecondsPerDay)
338 + else if (hours < HoursPerDay * 2)
339 {
324 - return pluralize(elapsed / SecondsPerHour, L"hour", L"hours");
340 + return Localization::WSLCCLI_RelativeTimeHours(hours);
341 }
326 - else if (elapsed < SecondsPerWeek)
342 + else if (hours < HoursPerDay * 7 * 2)
343 {
328 - return pluralize(elapsed / SecondsPerDay, L"day", L"days");
344 + return Localization::WSLCCLI_RelativeTimeDays(hours / HoursPerDay);
345 }
330 - else if (elapsed < SecondsPerMonth)
346 + else if (hours < HoursPerDay * 30 * 2)
347 {
332 - return pluralize(elapsed / SecondsPerWeek, L"week", L"weeks");
348 + return Localization::WSLCCLI_RelativeTimeWeeks(hours / HoursPerDay / 7);
349 }
334 - else if (elapsed < SecondsPerYear)
350 + else if (hours < HoursPerDay * 365 * 2)
351 {
336 - return pluralize(elapsed / SecondsPerMonth, L"month", L"months");
352 + return Localization::WSLCCLI_RelativeTimeMonths(hours / HoursPerDay / 30);
353 }
354
339 - return pluralize(elapsed / SecondsPerYear, L"year", L"years");
355 + return Localization::WSLCCLI_RelativeTimeYears(elapsed / SecondsPerHour / HoursPerDay / 365);
356 }
357
358 int ContainerService::Attach(Terminal& terminal, Session& session, const std::string& id)
src/windows/wslc/services/ContainerService.h
+1
@@ -24,6 +24,7 @@ struct ContainerService
24 {
25 static std::wstring ContainerStateToString(WSLCContainerState state, ULONGLONG stateChangedAt = 0);
26 static std::wstring FormatRelativeTime(ULONGLONG timestamp);
27 + static std::wstring FormatElapsedSeconds(LONGLONG elapsedSeconds);
28 static std::wstring FormatPorts(WSLCContainerState state, const std::vector<models::PortInformation>& ports);
29 static int Attach(Terminal& terminal, models::Session& session, const std::string& id);
30 static int Run(Terminal& terminal, models::Session& session, const std::string& image, models::ContainerOptions options);
src/windows/wslc/services/ImageModel.h
+24 -3
@@ -13,9 +13,6 @@ Abstract:
13 --*/
14 #pragma once
15
16 -// 1000*1000 instead of 1024*1024 to be consistent with Docker CLI's definition of megabyte (MB).
17 -#define WSLC_IMAGE_1MB (1000 * 1000)
18 -
16 namespace wsl::windows::wslc::models {
17 struct ImageInformation
18 {
@@ -24,10 +21,34 @@ struct ImageInformation
21 std::string Id;
22 LONGLONG Created{};
23 int64_t Size{};
24 + // Number of containers created from the image, or -1 when the count wasn't requested.
25 + LONGLONG Containers{-1};
26
27 NLOHMANN_DEFINE_TYPE_INTRUSIVE_WITH_DEFAULT(ImageInformation, Repository, Tag, Id, Created, Size);
28 };
29
30 +// The shape emitted by "image list --format json". Every value is reported as a string, and
31 +// "<none>" is used rather than null for missing repository, tag, and digest data, so this is kept
32 +// separate from ImageInformation, which mirrors the service's native types.
33 +struct ImageOutputInformation
34 +{
35 + std::string Containers;
36 + std::string CreatedAt;
37 + std::string CreatedSince;
38 + std::string Digest;
39 + std::string ID;
40 + std::string Repository;
41 + std::string SharedSize;
42 + std::string Size;
43 + std::string Tag;
44 + std::string UniqueSize;
45 +
46 + NLOHMANN_DEFINE_TYPE_INTRUSIVE_WITH_DEFAULT(
47 + ImageOutputInformation, Containers, CreatedAt, CreatedSince, Digest, ID, Repository, SharedSize, Size, Tag, UniqueSize);
48 +};
49 +
50 +inline constexpr std::string_view c_none = "<none>";
51 +
52 struct PruneImagesResult
53 {
54 std::vector<std::string> DeletedImages;
src/windows/wslc/services/ImageService.cpp
+3 -2
@@ -290,7 +290,7 @@ void ImageService::Build(
290 }
291
292 std::vector<ImageInformation> ImageService::List(
293 - wsl::windows::wslc::models::Session& session, const std::vector<std::pair<std::string, std::string>>& filters)
293 + wsl::windows::wslc::models::Session& session, const std::vector<std::pair<std::string, std::string>>& filters, bool containerCounts)
294 {
295 std::vector<WSLCFilter> filterEntries;
296 filterEntries.reserve(filters.size());
@@ -300,7 +300,7 @@ std::vector<ImageInformation> ImageService::List(
300 }
301
302 WSLCListImagesOptions options{};
303 - options.Flags = WSLCListImagesFlagsNone;
303 + options.Flags = containerCounts ? WSLCListImagesFlagsContainerCounts : WSLCListImagesFlagsNone;
304 options.Filters = filterEntries.empty() ? nullptr : filterEntries.data();
305 options.FiltersCount = static_cast<ULONG>(filterEntries.size());
306
@@ -326,6 +326,7 @@ std::vector<ImageInformation> ImageService::List(
326 info.Id = image.Hash;
327 info.Created = image.Created;
328 info.Size = image.Size;
329 + info.Containers = image.Containers;
330 result.push_back(info);
331 }
332
src/windows/wslc/services/ImageService.h
+5 -1
@@ -62,8 +62,12 @@ public:
62 IProgressCallback* callback,
63 HANDLE cancelEvent = nullptr);
64
65 + // Container counts are only gathered when requested: it costs an extra query, and the service
66 + // computes it alongside the image list so the two are consistent.
67 static std::vector<wsl::windows::wslc::models::ImageInformation> List(
66 - wsl::windows::wslc::models::Session& session, const std::vector<std::pair<std::string, std::string>>& filters = {});
68 + wsl::windows::wslc::models::Session& session,
69 + const std::vector<std::pair<std::string, std::string>>& filters = {},
70 + bool containerCounts = false);
71 static void Load(Terminal& terminal, wsl::windows::wslc::models::Session& session, const std::wstring& input, IImageLoadCallback* callback = nullptr);
72 static std::string Import(Terminal& terminal, wsl::windows::wslc::models::Session& session, const std::wstring& input, const std::string& imageName);
73 static void Delete(wsl::windows::wslc::models::Session& session, const std::string& image, bool force, bool noPrune);
src/windows/wslc/tasks/ImageTasks.cpp
+40 -8
@@ -23,6 +23,7 @@ Abstract:
23 #include "TableOutput.h"
24 #include "Task.h"
25 #include <format>
26 +#include <unordered_map>
27 #include <wslutil.h>
28
29 using namespace wsl::shared;
@@ -70,6 +71,31 @@ namespace {
71 Terminal& m_terminal;
72 };
73
74 + // Placeholder for values that are unavailable. wslc does not track image digests or layer sharing.
75 + constexpr std::string_view c_notAvailable = "N/A";
76 +
77 + // Builds the representation of an image, shared by the table and json output so the two cannot
78 + // drift. Every value is emitted as a string, "<none>" is used for missing repository/tag data,
79 + // and the id is truncated unless --no-trunc is passed, in which case it keeps the algorithm prefix.
80 + ImageOutputInformation ToImageOutput(const ImageInformation& image, bool truncate)
81 + {
82 + ImageOutputInformation entry;
83 + entry.Containers = image.Containers < 0 ? std::string{c_notAvailable} : std::to_string(image.Containers);
84 +
85 + entry.CreatedAt = FormatDockerTimestamp(image.Created);
86 + entry.CreatedSince =
87 + WideToMultiByte(ContainerService::FormatRelativeTime(image.Created > 0 ? static_cast<ULONGLONG>(image.Created) : 0));
88 + entry.Digest = c_none;
89 + entry.ID = truncate ? TruncateId(image.Id, true) : image.Id;
90 + entry.Repository = image.Repository.value_or(std::string{c_none});
91 + entry.SharedSize = c_notAvailable;
92 + entry.Size = WideToMultiByte(FormatDockerSize(static_cast<uint64_t>(std::max<int64_t>(image.Size, 0))));
93 + entry.Tag = image.Tag.value_or(std::string{c_none});
94 + entry.UniqueSize = c_notAvailable;
95 +
96 + return entry;
97 + }
98 +
99 } // namespace
100
101 static bool TryInspectImage(Terminal& terminal, Session& session, const std::string& imageId, std::optional<wslc_schema::InspectImage>& inspectData)
@@ -154,7 +180,12 @@ void GetImages(CLIExecutionContext& context)
180 // Filter values are parsed and cached during argument validation.
181 auto filters = context.Args.GetAllValues<ArgType::Filter>();
182
157 - auto images = ImageService::List(session, filters);
183 + // The container count is only reported by json output, and gathering it costs an extra query in
184 + // the service, so it is only requested when it will be shown.
185 + const bool containerCounts =
186 + context.Args.GetValue<ArgType::Format>(FormatType::Table) == FormatType::Json && !context.Args.GetValue<ArgType::Quiet>();
187 +
188 + auto images = ImageService::List(session, filters, containerCounts);
189 context.Data.Add<Data::Images>(std::move(images));
190 }
191
@@ -175,6 +206,7 @@ void ListImages(CLIExecutionContext& context)
206 }
207
208 const auto format = context.Args.GetValue<ArgType::Format>(FormatType::Table);
209 + bool trunc = !context.Args.GetValue<ArgType::NoTrunc>();
210
211 switch (format)
212 {
@@ -182,14 +214,13 @@ void ListImages(CLIExecutionContext& context)
214 {
215 for (const auto& image : images)
216 {
185 - context.Terminal.Output(L"{}\n", ToJsonW(image, c_jsonCompactIndent));
217 + context.Terminal.Output(L"{}\n", ToJsonW(ToImageOutput(image, trunc), c_jsonCompactIndent));
218 }
219
220 break;
221 }
222 case FormatType::Table:
223 {
192 - bool trunc = !context.Args.GetValue<ArgType::NoTrunc>();
224 using enum ColumnOverflow;
225
226 // Create table — only IMAGE ID uses fixed width; other columns shrink to fit the console.
@@ -208,12 +239,13 @@ void ListImages(CLIExecutionContext& context)
239
240 for (const auto& image : images)
241 {
242 + const auto entry = ToImageOutput(image, trunc);
243 table.WriteRow({
212 - MultiByteToWide(image.Repository.value_or("<untagged>")),
213 - MultiByteToWide(image.Tag.value_or("<untagged>")),
214 - MultiByteToWide(TruncateId(image.Id, trunc)),
215 - ContainerService::FormatRelativeTime(image.Created > 0 ? static_cast<ULONGLONG>(image.Created) : 0),
216 - std::format(L"{:.2f} MB", static_cast<double>(image.Size) / WSLC_IMAGE_1MB),
244 + MultiByteToWide(entry.Repository),
245 + MultiByteToWide(entry.Tag),
246 + MultiByteToWide(entry.ID),
247 + MultiByteToWide(entry.CreatedSince),
248 + MultiByteToWide(entry.Size),
249 });
250 }
251
src/windows/wslcsession/WSLCSession.cpp
+37
@@ -1840,6 +1840,7 @@ try
1840
1841 bool all = false;
1842 bool digests = false;
1843 + bool containerCounts = false;
1844 std::map<std::string, std::vector<std::string>> filters;
1845
1846 if (Options != nullptr)
@@ -1852,6 +1853,7 @@ try
1853
1854 all = WI_IsFlagSet(Options->Flags, WSLCListImagesFlagsAll);
1855 digests = WI_IsFlagSet(Options->Flags, WSLCListImagesFlagsDigests);
1856 + containerCounts = WI_IsFlagSet(Options->Flags, WSLCListImagesFlagsContainerCounts);
1857
1858 filters = wsl::windows::common::wslutil::ParseKeyMultiValuePairs(Options->Filters, Options->FiltersCount);
1859 }
@@ -1860,6 +1862,15 @@ try
1862
1863 THROW_HR_IF(HRESULT_FROM_WIN32(ERROR_INVALID_STATE), !m_runtime.HasDocker());
1864
1865 + // The container count is gathered under the container lock alongside the image list so that no
1866 + // container can be created or removed in between, which would report counts for a set of images
1867 + // that no longer matches the listing.
1868 + std::unique_lock<std::mutex> containersLock;
1869 + if (containerCounts)
1870 + {
1871 + containersLock = std::unique_lock{m_containersLock};
1872 + }
1873 +
1874 std::vector<docker_schema::Image> images;
1875 try
1876 {
@@ -1867,6 +1878,30 @@ try
1878 }
1879 CATCH_AND_THROW_DOCKER_USER_ERROR("Failed to list images");
1880
1881 + // Stopped containers are included, matching docker.
1882 + std::map<std::string, LONGLONG> containersByImage;
1883 + if (containerCounts)
1884 + {
1885 + try
1886 + {
1887 + for (const auto& container : m_runtime.Docker().ListContainers(true))
1888 + {
1889 + containersByImage[container.ImageID]++;
1890 + }
1891 + }
1892 + CATCH_AND_THROW_DOCKER_USER_ERROR("Failed to list containers");
1893 + }
1894 +
1895 + const auto containersForImage = [&](const std::string& id) {
1896 + if (!containerCounts)
1897 + {
1898 + return -1LL;
1899 + }
1900 +
1901 + const auto it = containersByImage.find(id);
1902 + return it == containersByImage.end() ? 0LL : it->second;
1903 + };
1904 +
1905 // Compute the number of entries - one entry per tag, or one per image if no tags
1906 auto entries = std::accumulate(images.begin(), images.end(), size_t{0}, [](auto sum, const auto& e) {
1907 return sum + (e.RepoTags.empty() ? 1 : e.RepoTags.size());
@@ -1907,6 +1942,7 @@ try
1942 THROW_HR_IF(E_UNEXPECTED, strcpy_s(output[index].ParentId, e.ParentId.c_str()) != 0);
1943 output[index].Size = e.Size;
1944 output[index].Created = e.Created;
1945 + output[index].Containers = containersForImage(e.Id);
1946 index++;
1947 }
1948 else
@@ -1933,6 +1969,7 @@ try
1969 THROW_HR_IF(E_UNEXPECTED, strcpy_s(output[index].ParentId, e.ParentId.c_str()) != 0);
1970 output[index].Size = e.Size;
1971 output[index].Created = e.Created;
1972 + output[index].Containers = containersForImage(e.Id);
1973 index++;
1974 }
1975 }
test/windows/StringUnitTests.cpp
+25
@@ -5,6 +5,7 @@
5 #include "string.hpp"
6
7 using wsl::windows::common::string::FormatBytes;
8 +using wsl::windows::common::string::FormatDockerSize;
9 using wsl::windows::common::string::FormatStorageSize;
10 using wsl::windows::common::string::ParseStorageSize;
11 using wsl::windows::common::string::StorageSizeUnit;
@@ -227,6 +228,30 @@ class StringUnitTests
228 VERIFY_ARE_EQUAL(std::wstring{L"119.86 MB"}, FormatBytes(119'856'765));
229 }
230
231 + // Docker renders image sizes with units.HumanSizeWithPrecision(size, 3), which is base 1000 with
232 + // three significant digits, no space, and "kB" rather than "KB".
233 + TEST_METHOD(FormatDockerSize_MatchesDockerPrecision)
234 + {
235 + const std::vector<std::pair<uint64_t, std::wstring>> TestCases{
236 + {0, L"0B"},
237 + {999, L"999B"},
238 + {1'000, L"1kB"},
239 + {1'500, L"1.5kB"},
240 + {7'050'000, L"7.05MB"},
241 + {119'856'765, L"120MB"},
242 + {1'090'000'000, L"1.09GB"},
243 + {1'000'000'000'000ULL, L"1TB"},
244 + };
245 +
246 + for (const auto& [bytes, expected] : TestCases)
247 + {
248 + VERIFY_ARE_EQUAL(expected, FormatDockerSize(bytes));
249 + }
250 +
251 + // Three significant digits switch to exponent form just below the next unit, matching Go's %g.
252 + VERIFY_ARE_EQUAL(std::wstring{L"1e+03MB"}, FormatDockerSize(999'900'000));
253 + }
254 +
255 TEST_METHOD(StorageSize_BytesToTextRoundTrips)
256 {
257 const auto VerifyRoundTrip = [](uint64_t Bytes, StorageSizeUnit Unit, uint32_t DecimalPlaces, bool IncludeSpace = false) {
test/windows/wslc/WSLCCLIRelativeTimeUnitTests.cpp new
+103
@@ -0,0 +1,103 @@
1 +// Copyright (C) Microsoft Corporation. All rights reserved.
2 +
3 +#include "precomp.h"
4 +#include "windows/Common.h"
5 +#include "WSLCCLITestHelpers.h"
6 +
7 +#include "ContainerService.h"
8 +
9 +using namespace wsl::shared;
10 +using namespace wsl::windows::wslc;
11 +using namespace wsl::windows::wslc::services;
12 +using namespace WSLCTestHelpers;
13 +using namespace WEX::Logging;
14 +using namespace WEX::Common;
15 +using namespace WEX::TestExecution;
16 +
17 +namespace WSLCCLIRelativeTimeUnitTests {
18 +
19 +class WSLCCLIRelativeTimeUnitTests
20 +{
21 + WSLC_TEST_CLASS(WSLCCLIRelativeTimeUnitTests)
22 +
23 + TEST_CLASS_SETUP(TestClassSetup)
24 + {
25 + return true;
26 + }
27 +
28 + TEST_CLASS_CLEANUP(TestClassCleanup)
29 + {
30 + return true;
31 + }
32 +
33 + static std::wstring FormatElapsed(LONGLONG secondsAgo)
34 + {
35 + return ContainerService::FormatElapsedSeconds(secondsAgo);
36 + }
37 +
38 + TEST_METHOD(RelativeTime_ZeroTimestamp_ReturnsEmpty)
39 + {
40 + VERIFY_ARE_EQUAL(std::wstring{}, ContainerService::FormatRelativeTime(0));
41 + }
42 +
43 + TEST_METHOD(RelativeTime_NegativeElapsed_ClampsToLessThanASecond)
44 + {
45 + VERIFY_ARE_EQUAL(Localization::WSLCCLI_RelativeTimeLessThanASecond(), ContainerService::FormatElapsedSeconds(-600));
46 + }
47 +
48 + TEST_METHOD(RelativeTime_Seconds)
49 + {
50 + VERIFY_ARE_EQUAL(Localization::WSLCCLI_RelativeTimeLessThanASecond(), FormatElapsed(0));
51 + VERIFY_ARE_EQUAL(Localization::WSLCCLI_RelativeTimeOneSecond(), FormatElapsed(1));
52 + VERIFY_ARE_EQUAL(Localization::WSLCCLI_RelativeTimeSeconds(2), FormatElapsed(2));
53 + VERIFY_ARE_EQUAL(Localization::WSLCCLI_RelativeTimeSeconds(59), FormatElapsed(59));
54 + }
55 +
56 + TEST_METHOD(RelativeTime_Minutes)
57 + {
58 + VERIFY_ARE_EQUAL(Localization::WSLCCLI_RelativeTimeAboutAMinute(), FormatElapsed(60));
59 + VERIFY_ARE_EQUAL(Localization::WSLCCLI_RelativeTimeAboutAMinute(), FormatElapsed(119));
60 + VERIFY_ARE_EQUAL(Localization::WSLCCLI_RelativeTimeMinutes(2), FormatElapsed(120));
61 + VERIFY_ARE_EQUAL(Localization::WSLCCLI_RelativeTimeMinutes(59), FormatElapsed(59 * 60));
62 + }
63 +
64 + TEST_METHOD(RelativeTime_Hours)
65 + {
66 + VERIFY_ARE_EQUAL(Localization::WSLCCLI_RelativeTimeAboutAnHour(), FormatElapsed(60 * 60));
67 + VERIFY_ARE_EQUAL(Localization::WSLCCLI_RelativeTimeHours(2), FormatElapsed(2 * 60 * 60));
68 + VERIFY_ARE_EQUAL(Localization::WSLCCLI_RelativeTimeHours(47), FormatElapsed(47 * 60 * 60));
69 + }
70 +
71 + // The hour count is rounded to the nearest hour rather than truncated.
72 + TEST_METHOD(RelativeTime_HoursAreRounded)
73 + {
74 + VERIFY_ARE_EQUAL(Localization::WSLCCLI_RelativeTimeAboutAnHour(), FormatElapsed(89 * 60));
75 + VERIFY_ARE_EQUAL(Localization::WSLCCLI_RelativeTimeHours(2), FormatElapsed(90 * 60));
76 + }
77 +
78 + TEST_METHOD(RelativeTime_Days)
79 + {
80 + VERIFY_ARE_EQUAL(Localization::WSLCCLI_RelativeTimeDays(2), FormatElapsed(48 * 60 * 60));
81 + VERIFY_ARE_EQUAL(Localization::WSLCCLI_RelativeTimeDays(13), FormatElapsed(13 * 24 * 60 * 60));
82 + }
83 +
84 + TEST_METHOD(RelativeTime_Weeks)
85 + {
86 + VERIFY_ARE_EQUAL(Localization::WSLCCLI_RelativeTimeWeeks(2), FormatElapsed(14 * 24 * 60 * 60));
87 + VERIFY_ARE_EQUAL(Localization::WSLCCLI_RelativeTimeWeeks(8), FormatElapsed(59 * 24 * 60 * 60));
88 + }
89 +
90 + TEST_METHOD(RelativeTime_Months)
91 + {
92 + VERIFY_ARE_EQUAL(Localization::WSLCCLI_RelativeTimeMonths(2), FormatElapsed(60 * 24 * 60 * 60));
93 + VERIFY_ARE_EQUAL(Localization::WSLCCLI_RelativeTimeMonths(12), FormatElapsed(365 * 24 * 60 * 60));
94 + }
95 +
96 + TEST_METHOD(RelativeTime_Years)
97 + {
98 + VERIFY_ARE_EQUAL(Localization::WSLCCLI_RelativeTimeYears(2), FormatElapsed(730 * 24 * 60 * 60LL));
99 + VERIFY_ARE_EQUAL(Localization::WSLCCLI_RelativeTimeYears(3), FormatElapsed(3 * 365 * 24 * 60 * 60LL));
100 + }
101 +};
102 +
103 +} // namespace WSLCCLIRelativeTimeUnitTests
test/windows/wslc/e2e/WSLCE2EHelpers.cpp
+9 -9
@@ -210,7 +210,7 @@ void VerifyImageIsListed(const TestImage& image)
210 {
211 auto result = RunWslc(L"image list --format json");
212 result.Verify({.Stderr = L"", .ExitCode = 0});
213 - auto images = ParseNdjsonOutputAs<wsl::windows::wslc::models::ImageInformation>(result);
213 + auto images = ParseNdjsonOutputAs<wsl::windows::wslc::models::ImageOutputInformation>(result);
214 for (const auto& img : images)
215 {
216 if (img.Repository == wsl::shared::string::WideToMultiByte(image.Name) &&
@@ -387,7 +387,7 @@ void EnsureImageIsDeleted(const TestImage& image)
387 auto result = RunWslc(L"image list --format json");
388 result.Verify({.Stderr = L"", .ExitCode = 0});
389
390 - auto images = ParseNdjsonOutputAs<wsl::windows::wslc::models::ImageInformation>(result);
390 + auto images = ParseNdjsonOutputAs<wsl::windows::wslc::models::ImageOutputInformation>(result);
391 for (const auto& img : images)
392 {
393 if (img.Repository == wsl::shared::string::WideToMultiByte(image.Name) &&
@@ -406,16 +406,16 @@ void DeleteImagesWithRepositoryPrefix(const std::wstring& repositoryPrefix)
406 auto result = RunWslc(L"image list --format json");
407 result.Verify({.Stderr = L"", .ExitCode = 0});
408
409 - const auto images = ParseNdjsonOutputAs<wsl::windows::wslc::models::ImageInformation>(result);
409 + const auto images = ParseNdjsonOutputAs<wsl::windows::wslc::models::ImageOutputInformation>(result);
410 const auto prefix = wsl::shared::string::WideToMultiByte(repositoryPrefix);
411 for (const auto& image : images)
412 {
413 - if (image.Repository && image.Tag && image.Repository->starts_with(prefix))
413 + if (image.Repository.starts_with(prefix))
414 {
415 // No container cleanup here: the images this prunes are only ever built and inspected, never used to
416 // create containers, so image delete --force is sufficient. If a future test containerizes a built
417 // image, remove its container in that test's cleanup rather than broadening this prefix-based safety net.
418 - const auto nameAndTag = wsl::shared::string::MultiByteToWide(std::format("{}:{}", *image.Repository, *image.Tag));
418 + const auto nameAndTag = wsl::shared::string::MultiByteToWide(std::format("{}:{}", image.Repository, image.Tag));
419 RunWslc(std::format(L"image delete --force {}", nameAndTag)).Verify({.Stderr = L"", .ExitCode = 0});
420 }
421 }
@@ -423,14 +423,14 @@ void DeleteImagesWithRepositoryPrefix(const std::wstring& repositoryPrefix)
423
424 void EnsureNoUntaggedImages()
425 {
426 - auto result = RunWslc(L"image list --format json --filter dangling=true");
426 + auto result = RunWslc(L"image list --format json --no-trunc --filter dangling=true");
427 result.Verify({.Stderr = L"", .ExitCode = 0});
428
429 - const auto images = ParseNdjsonOutputAs<wsl::windows::wslc::models::ImageInformation>(result);
429 + const auto images = ParseNdjsonOutputAs<wsl::windows::wslc::models::ImageOutputInformation>(result);
430
431 for (const auto& image : images)
432 {
433 - const auto id = wsl::shared::string::MultiByteToWide(GetHashId(image.Id, true));
433 + const auto id = wsl::shared::string::MultiByteToWide(GetHashId(image.ID, true));
434 auto deleteResult = RunWslc(std::format(L"image delete --force {}", id));
435
436 // Tolerate WSLC_E_IMAGE_NOT_FOUND - an untagged image may already be gone if it was a
@@ -454,7 +454,7 @@ void EnsureImageIsLoaded(const TestImage& image, const std::wstring& sessionName
454 auto result = RunWslc(listCommand);
455 result.Verify({.Stderr = L"", .ExitCode = 0});
456
457 - auto images = ParseNdjsonOutputAs<wsl::windows::wslc::models::ImageInformation>(result);
457 + auto images = ParseNdjsonOutputAs<wsl::windows::wslc::models::ImageOutputInformation>(result);
458 for (const auto& img : images)
459 {
460 if (img.Repository == wsl::shared::string::WideToMultiByte(image.Name) &&
test/windows/wslc/e2e/WSLCE2EImageImportTests.cpp
+2 -2
@@ -103,11 +103,11 @@ class WSLCE2EImageImportTests
103 auto countUntaggedImages = [&]() {
104 auto result = RunWslc(L"image list --format json");
105 result.Verify({.Stderr = L"", .ExitCode = 0});
106 - auto images = ParseNdjsonOutputAs<wsl::windows::wslc::models::ImageInformation>(result);
106 + auto images = ParseNdjsonOutputAs<wsl::windows::wslc::models::ImageOutputInformation>(result);
107 size_t count = 0;
108 for (const auto& img : images)
109 {
110 - if (!img.Repository.has_value() || img.Repository.value() == "<none>")
110 + if (img.Repository == wsl::windows::wslc::models::c_none)
111 {
112 count++;
113 }
test/windows/wslc/e2e/WSLCE2EImageListTests.cpp
+178 -22
@@ -64,10 +64,11 @@ class WSLCE2EImageListTests
64
65 WSLC_TEST_METHOD(WSLCE2E_Image_List_QuietOption_OutputsIdsOnly)
66 {
67 - // Get the expected image ID from JSON output.
68 - auto jsonResult = RunWslc(L"image list --format json");
67 + // Get the expected image ID from JSON output. --no-trunc is required because json output
68 + // truncates the id by default.
69 + auto jsonResult = RunWslc(L"image list --format json --no-trunc");
70 jsonResult.Verify({.Stderr = L"", .ExitCode = 0});
70 - const auto images = ParseNdjsonOutputAs<ImageInformation>(jsonResult);
71 + const auto images = ParseNdjsonOutputAs<ImageOutputInformation>(jsonResult);
72
73 std::string debianId;
74 for (const auto& image : images)
@@ -75,7 +76,7 @@ class WSLCE2EImageListTests
76 if (image.Repository == wsl::shared::string::WideToMultiByte(DebianImage.Name) &&
77 image.Tag == wsl::shared::string::WideToMultiByte(DebianImage.Tag))
78 {
78 - debianId = image.Id;
79 + debianId = image.ID;
80 break;
81 }
82 }
@@ -128,7 +129,7 @@ class WSLCE2EImageListTests
129 const auto result = RunWslc(L"image list --format json");
130 result.Verify({.Stderr = L"", .ExitCode = 0});
131
131 - const auto images = ParseNdjsonOutputAs<ImageInformation>(result);
132 + const auto images = ParseNdjsonOutputAs<ImageOutputInformation>(result);
133
134 VERIFY_IS_GREATER_THAN_OR_EQUAL(images.size(), 2u);
135
@@ -136,9 +137,7 @@ class WSLCE2EImageListTests
137 for (const auto& image : images)
138 {
139 auto nameAndTag = std::format(
139 - L"{}:{}",
140 - wsl::shared::string::MultiByteToWide(image.Repository.value_or("<untagged>")),
141 - wsl::shared::string::MultiByteToWide(image.Tag.value_or("<untagged>")));
140 + L"{}:{}", wsl::shared::string::MultiByteToWide(image.Repository), wsl::shared::string::MultiByteToWide(image.Tag));
141 imageNames.push_back(nameAndTag);
142 }
143
@@ -146,6 +145,102 @@ class WSLCE2EImageListTests
145 VERIFY_ARE_NOT_EQUAL(imageNames.end(), std::find(imageNames.begin(), imageNames.end(), AlpineImage.NameAndTag()));
146 }
147
148 + WSLC_TEST_METHOD(WSLCE2E_Image_List_JsonFormat_MatchesDockerShape)
149 + {
150 + const std::set<std::string> expectedKeys = {
151 + "Containers", "CreatedAt", "CreatedSince", "Digest", "ID", "Repository", "SharedSize", "Size", "Tag", "UniqueSize"};
152 +
153 + const auto result = RunWslc(L"image list --format json");
154 + result.Verify({.Stderr = L"", .ExitCode = 0});
155 +
156 + const auto entries = ParseNdjsonOutput(result);
157 + VERIFY_IS_GREATER_THAN_OR_EQUAL(entries.size(), 2u);
158 +
159 + for (const auto& entry : entries)
160 + {
161 + std::set<std::string> keys;
162 + for (const auto& [key, value] : entry.items())
163 + {
164 + keys.insert(key);
165 + VERIFY_IS_TRUE(value.is_string(), wsl::shared::string::MultiByteToWide(std::format("'{}' must be a string", key)).c_str());
166 + }
167 +
168 + VERIFY_ARE_EQUAL(expectedKeys, keys, L"json output must contain exactly docker's image fields");
169 +
170 + VERIFY_ARE_NOT_EQUAL(std::string{}, entry["Repository"].get<std::string>());
171 + VERIFY_ARE_NOT_EQUAL(std::string{}, entry["Tag"].get<std::string>());
172 + VERIFY_ARE_EQUAL(std::string{c_none}, entry["Digest"].get<std::string>());
173 +
174 + const auto containers = entry["Containers"].get<std::string>();
175 + VERIFY_IS_FALSE(containers.empty());
176 + VERIFY_IS_TRUE(
177 + std::ranges::all_of(containers, [](char value) { return std::isdigit(static_cast<unsigned char>(value)) != 0; }),
178 + L"'Containers' must be a container count");
179 + }
180 + }
181 +
182 + WSLC_TEST_METHOD(WSLCE2E_Image_List_JsonFormat_ReportsContainerCount)
183 + {
184 + // Every container created from an image is counted, including containers that were never
185 + // started, and the count is reported against every tag of that image.
186 + constexpr auto containerName = L"wslc-image-list-container-count";
187 + EnsureContainerDoesNotExist(containerName);
188 +
189 + auto containerCount = [](const TestImage& image) {
190 + const auto result = RunWslc(L"image list --format json");
191 + result.Verify({.Stderr = L"", .ExitCode = 0});
192 +
193 + for (const auto& entry : ParseNdjsonOutputAs<ImageOutputInformation>(result))
194 + {
195 + if (entry.Repository == wsl::shared::string::WideToMultiByte(image.Name) &&
196 + entry.Tag == wsl::shared::string::WideToMultiByte(image.Tag))
197 + {
198 + return std::stoi(entry.Containers);
199 + }
200 + }
201 +
202 + VERIFY_FAIL(std::format(L"Image '{}' not found in image list output", image.NameAndTag()).c_str());
203 + return -1;
204 + };
205 +
206 + const auto alpineBaseline = containerCount(AlpineImage);
207 + const auto debianBaseline = containerCount(DebianImage);
208 +
209 + auto createResult = RunWslc(std::format(L"container create --name {} {}", containerName, AlpineImage.NameAndTag()));
210 + createResult.Verify({.Stderr = L"", .ExitCode = 0});
211 + auto cleanup = wil::scope_exit([&]() { EnsureContainerDoesNotExist(containerName); });
212 +
213 + VERIFY_ARE_EQUAL(alpineBaseline + 1, containerCount(AlpineImage), L"a created container must be counted");
214 + VERIFY_ARE_EQUAL(debianBaseline, containerCount(DebianImage), L"only the image the container was created from is counted");
215 +
216 + cleanup.reset();
217 +
218 + VERIFY_ARE_EQUAL(alpineBaseline, containerCount(AlpineImage), L"a removed container must no longer be counted");
219 + }
220 +
221 + WSLC_TEST_METHOD(WSLCE2E_Image_List_JsonFormat_TruncatesIdByDefault)
222 + {
223 + // The id is truncated to 12 hex characters unless --no-trunc is passed, in which case it
224 + // keeps the sha256: prefix.
225 + auto truncResult = RunWslc(L"image list --format json");
226 + truncResult.Verify({.Stderr = L"", .ExitCode = 0});
227 +
228 + for (const auto& image : ParseNdjsonOutputAs<ImageOutputInformation>(truncResult))
229 + {
230 + VERIFY_ARE_EQUAL(12u, image.ID.size(), L"json ids must be truncated to 12 characters by default");
231 + VERIFY_IS_FALSE(image.ID.starts_with("sha256:"));
232 + }
233 +
234 + auto noTruncResult = RunWslc(L"image list --format json --no-trunc");
235 + noTruncResult.Verify({.Stderr = L"", .ExitCode = 0});
236 +
237 + for (const auto& image : ParseNdjsonOutputAs<ImageOutputInformation>(noTruncResult))
238 + {
239 + VERIFY_IS_TRUE(image.ID.starts_with("sha256:"), L"--no-trunc ids must keep the algorithm prefix");
240 + VERIFY_IS_GREATER_THAN(image.ID.size(), 12u);
241 + }
242 + }
243 +
244 WSLC_TEST_METHOD(WSLCE2E_Image_List_TableFormat_HasExpectedColumns)
245 {
246 const auto result = RunWslc(L"image list");
@@ -166,6 +261,67 @@ class WSLCE2EImageListTests
261 VERIFY_IS_TRUE(foundHeader, L"Expected table header with REPOSITORY, TAG, IMAGE ID, CREATED, SIZE columns");
262 }
263
264 + WSLC_TEST_METHOD(WSLCE2E_Image_List_TableFormat_MatchesJsonValues)
265 + {
266 + // The table and json output must report the same values, formatted the way docker formats
267 + // them: SI sizes ("120MB", not "119.86 MB") and "<none>" for missing repository/tag data.
268 + const auto jsonResult = RunWslc(L"image list --format json");
269 + jsonResult.Verify({.Stderr = L"", .ExitCode = 0});
270 +
271 + const auto tableResult = RunWslc(L"image list");
272 + tableResult.Verify({.Stderr = L"", .ExitCode = 0});
273 + const auto tableLines = tableResult.GetStdoutLines();
274 +
275 + const auto images = ParseNdjsonOutputAs<ImageOutputInformation>(jsonResult);
276 + VERIFY_IS_GREATER_THAN_OR_EQUAL(images.size(), 2u);
277 +
278 + for (const auto& image : images)
279 + {
280 + const auto row = std::format(
281 + L"{} {} {} {}",
282 + wsl::shared::string::MultiByteToWide(image.Repository),
283 + wsl::shared::string::MultiByteToWide(image.Tag),
284 + wsl::shared::string::MultiByteToWide(image.ID),
285 + wsl::shared::string::MultiByteToWide(image.Size));
286 +
287 + const bool found = std::ranges::any_of(tableLines, [&](const auto& line) {
288 + // Columns are padded, so match on the individual values rather than the row text.
289 + return line.find(wsl::shared::string::MultiByteToWide(image.ID)) != std::wstring::npos &&
290 + line.find(wsl::shared::string::MultiByteToWide(image.Repository)) != std::wstring::npos &&
291 + line.find(wsl::shared::string::MultiByteToWide(image.Tag)) != std::wstring::npos &&
292 + line.find(wsl::shared::string::MultiByteToWide(image.Size)) != std::wstring::npos &&
293 + line.find(wsl::shared::string::MultiByteToWide(image.CreatedSince)) != std::wstring::npos;
294 + });
295 +
296 + VERIFY_IS_TRUE(found, std::format(L"Table output has no row matching json values: {}", row).c_str());
297 + }
298 +
299 + // An untagged image is never labeled "<untagged>" in either format.
300 + for (const auto& line : tableLines)
301 + {
302 + VERIFY_ARE_EQUAL(std::wstring::npos, line.find(L"<untagged>"), L"table must use the '<none>' placeholder");
303 + }
304 + }
305 +
306 + WSLC_TEST_METHOD(WSLCE2E_Image_List_TableFormat_NoTruncKeepsAlgorithmPrefix)
307 + {
308 + // The --no-trunc table keeps the "sha256:" prefix on the image id.
309 + const auto result = RunWslc(L"image list --no-trunc");
310 + result.Verify({.Stderr = L"", .ExitCode = 0});
311 +
312 + auto lines = result.GetStdoutLines();
313 + VERIFY_IS_GREATER_THAN_OR_EQUAL(lines.size(), 2u);
314 +
315 + for (size_t i = 1; i < lines.size(); i++)
316 + {
317 + if (!lines[i].empty())
318 + {
319 + VERIFY_ARE_NOT_EQUAL(
320 + std::wstring::npos, lines[i].find(L"sha256:"), L"--no-trunc table ids must keep the algorithm prefix");
321 + }
322 + }
323 + }
324 +
325 WSLC_TEST_METHOD(WSLCE2E_Image_List_Filter_MalformedValue)
326 {
327 // Filter values must be of the form key=value; bare keys are rejected by the CLI.
@@ -188,14 +344,12 @@ class WSLCE2EImageListTests
344 auto listNames = [&](const std::wstring& filterArgs) {
345 auto r = RunWslc(std::format(L"image list --format json {}", filterArgs));
346 r.Verify({.Stderr = L"", .ExitCode = 0});
191 - const auto images = ParseNdjsonOutputAs<ImageInformation>(r);
347 + const auto images = ParseNdjsonOutputAs<ImageOutputInformation>(r);
348 std::set<std::wstring> names;
349 for (const auto& image : images)
350 {
351 names.insert(std::format(
196 - L"{}:{}",
197 - wsl::shared::string::MultiByteToWide(image.Repository.value_or("<untagged>")),
198 - wsl::shared::string::MultiByteToWide(image.Tag.value_or("<untagged>"))));
352 + L"{}:{}", wsl::shared::string::MultiByteToWide(image.Repository), wsl::shared::string::MultiByteToWide(image.Tag)));
353 }
354 return names;
355 };
@@ -234,7 +388,7 @@ class WSLCE2EImageListTests
388 auto result = RunWslc(L"image list --format json --filter dangling=false");
389 result.Verify({.Stderr = L"", .ExitCode = 0});
390
237 - auto images = ParseNdjsonOutputAs<ImageInformation>(result);
391 + auto images = ParseNdjsonOutputAs<ImageOutputInformation>(result);
392 bool foundDebian = false;
393 for (const auto& image : images)
394 {
@@ -250,11 +404,12 @@ class WSLCE2EImageListTests
404 result = RunWslc(L"image list --format json --filter dangling=true");
405 result.Verify({.Stderr = L"", .ExitCode = 0});
406
253 - images = ParseNdjsonOutputAs<ImageInformation>(result);
407 + images = ParseNdjsonOutputAs<ImageOutputInformation>(result);
408 for (const auto& image : images)
409 {
256 - VERIFY_IS_FALSE(
257 - image.Repository.has_value() && image.Repository.value() != "<none>",
410 + VERIFY_ARE_EQUAL(
411 + std::string{wsl::windows::wslc::models::c_none},
412 + image.Repository,
413 L"dangling=true list should not contain tagged images");
414 }
415 }
@@ -267,11 +422,11 @@ class WSLCE2EImageListTests
422 RunWslc(std::format(L"image list --format json --filter reference={} --filter dangling=false", DebianImage.Name));
423 result.Verify({.Stderr = L"", .ExitCode = 0});
424
270 - const auto images = ParseNdjsonOutputAs<ImageInformation>(result);
425 + const auto images = ParseNdjsonOutputAs<ImageOutputInformation>(result);
426 bool foundDebian = false;
427 for (const auto& image : images)
428 {
274 - const auto repo = wsl::shared::string::MultiByteToWide(image.Repository.value_or(""));
429 + const auto repo = wsl::shared::string::MultiByteToWide(image.Repository);
430 VERIFY_ARE_NOT_EQUAL(AlpineImage.Name, repo, L"alpine should not appear when filtering by reference=debian");
431 if (repo == DebianImage.Name)
432 {
@@ -283,17 +438,18 @@ class WSLCE2EImageListTests
438
439 WSLC_TEST_METHOD(WSLCE2E_Image_List_NoTrunc_ShowsFullImageId)
440 {
286 - // Pull the full image id from JSON output (always untruncated).
287 - auto jsonResult = RunWslc(L"image list --format json");
441 + // Pull the full image id from JSON output. --no-trunc is required because json output
442 + // truncates the id by default.
443 + auto jsonResult = RunWslc(L"image list --format json --no-trunc");
444 jsonResult.Verify({.Stderr = L"", .ExitCode = 0});
289 - const auto images = ParseNdjsonOutputAs<ImageInformation>(jsonResult);
445 + const auto images = ParseNdjsonOutputAs<ImageOutputInformation>(jsonResult);
446
447 std::string fullDebianId;
448 for (const auto& image : images)
449 {
450 if (image.Repository == wsl::shared::string::WideToMultiByte(DebianImage.Name))
451 {
296 - fullDebianId = image.Id;
452 + fullDebianId = image.ID;
453 break;
454 }
455 }