CLI: Add Reporter for structured CLI output with VT color and level routing (#40798)
* Reporter unified CLI output
David Bennett committed
Jun 17, 2026 at 12:24 UTC
8e3c0ad8b71714a737d165a6160a892714c2b4c8
11 files changed
+807
-7
src/windows/common/VTSupport.cpp
+7
@@ -441,6 +441,13 @@ namespace Format {
441
<< WSL_WINDOWS_VT_ESCAPE << L"\\";
442
return ConstructedSequence{std::move(result).str()};
443
}
444
+
445
+ ConstructedSequence LinkOpen(const std::wstring& url)
446
+ {
447
+ return ConstructedSequence{std::format(WSL_WINDOWS_VT_OSC L"8;;{}" WSL_WINDOWS_VT_ESCAPE L"\\", url)};
448
+ }
449
+
450
+ const Sequence LinkClose{WSL_WINDOWS_VT_OSC L"8;;" WSL_WINDOWS_VT_ESCAPE L"\\"};
451
} // namespace Format
452
453
namespace Erase {
src/windows/common/VTSupport.h
+2
@@ -311,6 +311,8 @@ namespace Format {
311
} // namespace Bg
312
313
ConstructedSequence Hyperlink(const std::wstring& text, const std::wstring& ref);
314
+ ConstructedSequence LinkOpen(const std::wstring& url);
315
+ extern const Sequence LinkClose;
316
} // namespace Format
317
318
// Line and screen erasure sequences.
src/windows/wslc/core/CLIExecutionContext.cpp
+5
-1
@@ -4,6 +4,7 @@ Copyright (c) Microsoft. All rights reserved.
4
5
--*/
6
#include "precomp.h"
7
+#include "Argument.h"
8
#include "CLIExecutionContext.h"
9
10
namespace wsl::windows::wslc::execution {
@@ -18,7 +19,10 @@ HANDLE CLIExecutionContext::CreateCancelEvent()
19
// This method should be idempotent.
20
void CLIExecutionContext::ApplyGlobalOptions()
21
{
21
- // TODO: Add per-global side effects here as features land.
22
+ if (GlobalArgs.Contains(ArgType::NoColor))
23
+ {
24
+ Reporter.SetNoColor(true);
25
+ }
26
}
27
28
} // namespace wsl::windows::wslc::execution
src/windows/wslc/core/CLIExecutionContext.h
+5
-2
@@ -14,6 +14,7 @@ Abstract:
14
#pragma once
15
#include "ArgumentTypes.h"
16
#include "ExecutionContextData.h"
17
+#include "Reporter.h"
18
#include <optional>
19
20
namespace wsl::windows::wslc::execution {
@@ -26,8 +27,7 @@ struct CLIExecutionContext : public wsl::windows::common::ExecutionContext
27
~CLIExecutionContext() override = default;
28
29
NON_COPYABLE(CLIExecutionContext);
29
- CLIExecutionContext(CLIExecutionContext&&) = default;
30
- CLIExecutionContext& operator=(CLIExecutionContext&&) = default;
30
+ NON_MOVABLE(CLIExecutionContext);
31
32
// Per-subcommand arguments parsed by the resolved leaf Command.
33
argument::ArgMap Args;
@@ -39,6 +39,9 @@ struct CLIExecutionContext : public wsl::windows::common::ExecutionContext
39
// Map of data stored in the context.
40
DataMap Data;
41
42
+ // Central output reporter for all user-facing status messages.
43
+ Reporter Reporter;
44
+
45
// Process exit code set by tasks like Run/Exec.
46
std::optional<int> ExitCode;
47
src/windows/wslc/core/Main.cpp
+17
-4
@@ -127,25 +127,38 @@ try
127
catch (...)
128
{
129
LOG_CAUGHT_EXCEPTION();
130
- result = wil::ResultFromCaughtException();
130
131
+ // If the user pressed Ctrl-C, acknowledge the cancellation and exit.
132
if (context.CancelEvent && context.CancelEvent.is_signaled())
133
{
134
- fwprintf(stderr, L"\nCancelled.\n");
134
+ // Cancel events are often considered warnings rather than errors, as the user
135
+ // intentionally triggered it.
136
+ const auto strings = wslutil::ErrorToString({.Code = HRESULT_FROM_WIN32(ERROR_CANCELLED)});
137
+ context.Reporter.Warn(L"\n{}\n", strings.Message);
138
+
139
+ // Exit with code 1 is consistent with Docker build and pull, but the POSIX-convention
140
+ // for cancellation is exit code 130, which is used by Docker compose and most shells.
141
+ // TODO: Consider switching to 130 or differentiate the cancellation types when we have
142
+ // more than image cancellation supported.
143
return 1;
144
}
145
146
+ // Using WSL shared utility to get the HRESULT from the caught exception.
147
+ // CLIExecutionContext is a derived class of wsl::windows::common::ExecutionContext.
148
+ result = wil::ResultFromCaughtException();
149
+
150
if (FAILED(result))
151
{
152
if (const auto& reported = context.ReportedError())
153
{
154
auto strings = wslutil::ErrorToString(*reported);
155
auto errorMessage = strings.Message.empty() ? strings.Code : strings.Message;
144
- wslutil::PrintMessage(Localization::MessageErrorCode(errorMessage, wslutil::ErrorCodeToString(result)), stderr);
156
+ context.Reporter.Error(L"{}\n", Localization::MessageErrorCode(errorMessage, wslutil::ErrorCodeToString(result)));
157
}
158
else
159
{
148
- wslutil::PrintMessage(Localization::MessageErrorCode("", wslutil::ErrorCodeToString(result)), stderr);
160
+ // Fallback for errors without context
161
+ context.Reporter.Error(L"{}\n", Localization::MessageErrorCode(L"", wslutil::ErrorCodeToString(result)));
162
}
163
}
164
}
src/windows/wslc/core/OutputChannel.cpp
new
+91
@@ -0,0 +1,91 @@
1
+/*++
2
+
3
+Copyright (c) Microsoft. All rights reserved.
4
+
5
+Module Name:
6
+
7
+ OutputChannel.cpp
8
+
9
+Abstract:
10
+
11
+ Implementation of OutputChannel.
12
+
13
+--*/
14
+#include "precomp.h"
15
+#include "OutputChannel.h"
16
+
17
+#include <algorithm>
18
+#include <cerrno>
19
+
20
+namespace wsl::windows::wslc {
21
+
22
+OutputChannel::OutputChannel(HANDLE consoleHandle, FILE* fallbackFile)
23
+{
24
+ DWORD mode = 0;
25
+ if (consoleHandle != INVALID_HANDLE_VALUE && consoleHandle != nullptr && GetConsoleMode(consoleHandle, &mode))
26
+ {
27
+ m_consoleHandle = consoleHandle;
28
+ m_vtMode.emplace(consoleHandle);
29
+ }
30
+ else
31
+ {
32
+ WI_ASSERT(fallbackFile != nullptr);
33
+ m_file = fallbackFile;
34
+ }
35
+}
36
+
37
+OutputChannel::OutputChannel(FILE* file, bool vtOverride) : m_file(file), m_vtOverride(vtOverride)
38
+{
39
+ WI_ASSERT(file != nullptr);
40
+}
41
+
42
+void OutputChannel::WriteString(std::wstring_view text) const
43
+{
44
+ if (text.empty())
45
+ {
46
+ return;
47
+ }
48
+
49
+ if (m_consoleHandle != nullptr)
50
+ {
51
+ DWORD written = 0;
52
+ LOG_IF_WIN32_BOOL_FALSE(WriteConsoleW(m_consoleHandle, text.data(), static_cast<DWORD>(text.size()), &written, nullptr));
53
+ return;
54
+ }
55
+
56
+ if (fwprintf(m_file, L"%.*ls", static_cast<int>(text.size()), text.data()) < 0)
57
+ {
58
+ const int err = errno;
59
+ LOG_HR_MSG(HRESULT_FROM_WIN32(ERROR_WRITE_FAULT), "fwprintf to redirected output failed (errno=%d)", err);
60
+ }
61
+}
62
+
63
+std::optional<int> OutputChannel::GetConsoleWidth() const
64
+{
65
+ if (m_consoleHandle == nullptr)
66
+ {
67
+ return std::nullopt;
68
+ }
69
+
70
+ CONSOLE_SCREEN_BUFFER_INFO info{};
71
+ if (!GetConsoleScreenBufferInfo(m_consoleHandle, &info))
72
+ {
73
+ return std::nullopt;
74
+ }
75
+
76
+ // (Right - Left + 1) is the visible width; subtract one more as an autowrap guard.
77
+ return std::max(0, static_cast<int>(info.srWindow.Right) - static_cast<int>(info.srWindow.Left));
78
+}
79
+
80
+bool OutputChannel::IsVTEnabled() const noexcept
81
+{
82
+ if (m_consoleHandle != nullptr)
83
+ {
84
+ DWORD mode = 0;
85
+ return GetConsoleMode(m_consoleHandle, &mode) && WI_IsFlagSet(mode, ENABLE_VIRTUAL_TERMINAL_PROCESSING);
86
+ }
87
+
88
+ return m_vtOverride;
89
+}
90
+
91
+} // namespace wsl::windows::wslc
src/windows/wslc/core/OutputChannel.h
new
+53
@@ -0,0 +1,53 @@
1
+/*++
2
+
3
+Copyright (c) Microsoft. All rights reserved.
4
+
5
+Module Name:
6
+
7
+ OutputChannel.h
8
+
9
+Abstract:
10
+
11
+ Byte sink used by Reporter. Each WriteString call is a single WriteConsoleW
12
+ or fwprintf. For console destinations the channel owns VT enablement (RAII).
13
+
14
+--*/
15
+#pragma once
16
+
17
+#include "VTSupport.h"
18
+#include "defs.h"
19
+
20
+#include <cstdio>
21
+#include <optional>
22
+#include <string_view>
23
+#include <Windows.h>
24
+
25
+namespace wsl::windows::wslc {
26
+
27
+class OutputChannel
28
+{
29
+public:
30
+ NON_COPYABLE(OutputChannel);
31
+ NON_MOVABLE(OutputChannel);
32
+
33
+ // Console path: probes handle, enables VT; falls back to fallbackFile when redirected.
34
+ OutputChannel(HANDLE consoleHandle, FILE* fallbackFile);
35
+
36
+ // FILE* path with explicit VT override (for tests).
37
+ OutputChannel(FILE* file, bool vtOverride);
38
+
39
+ void WriteString(std::wstring_view text) const;
40
+
41
+ // Console write width minus one (autowrap guard), or nullopt when redirected.
42
+ std::optional<int> GetConsoleWidth() const;
43
+
44
+ bool IsVTEnabled() const noexcept;
45
+
46
+private:
47
+ HANDLE m_consoleHandle = nullptr;
48
+ FILE* m_file = nullptr;
49
+ bool m_vtOverride = false;
50
+ std::optional<wsl::windows::common::vt::EnableVirtualTerminal> m_vtMode;
51
+};
52
+
53
+} // namespace wsl::windows::wslc
src/windows/wslc/core/Reporter.cpp
new
+63
@@ -0,0 +1,63 @@
1
+/*++
2
+
3
+Copyright (c) Microsoft. All rights reserved.
4
+
5
+Module Name:
6
+
7
+ Reporter.cpp
8
+
9
+Abstract:
10
+
11
+ Implementation of Reporter.
12
+
13
+--*/
14
+#include "precomp.h"
15
+#include "Reporter.h"
16
+
17
+namespace wsl::windows::wslc {
18
+
19
+using namespace wsl::windows::common::vt;
20
+
21
+Reporter::Reporter() : m_out(GetStdHandle(STD_OUTPUT_HANDLE), stdout), m_err(GetStdHandle(STD_ERROR_HANDLE), stderr)
22
+{
23
+}
24
+
25
+Reporter::Reporter(FILE* outFile, bool outVtEnabled, FILE* errFile, bool errVtEnabled) :
26
+ m_out(outFile, outVtEnabled), m_err(errFile, errVtEnabled)
27
+{
28
+}
29
+
30
+std::wstring_view Reporter::LevelPrefix(Level level) const noexcept
31
+{
32
+ if (!IsColorEnabled(level))
33
+ {
34
+ return {};
35
+ }
36
+
37
+ switch (level)
38
+ {
39
+ case Level::Warning:
40
+ return Format::Fg::BrightYellow.Get();
41
+ case Level::Error:
42
+ return Format::Fg::BrightRed.Get();
43
+ default:
44
+ return {};
45
+ }
46
+}
47
+
48
+bool Reporter::IsVTEnabled(Level level) const noexcept
49
+{
50
+ return ChannelFor(level).IsVTEnabled();
51
+}
52
+
53
+bool Reporter::IsColorEnabled(Level level) const noexcept
54
+{
55
+ return ChannelFor(level).IsVTEnabled() && !m_noColor;
56
+}
57
+
58
+std::optional<int> Reporter::GetConsoleWidth(Level level) const
59
+{
60
+ return ChannelFor(level).GetConsoleWidth();
61
+}
62
+
63
+} // namespace wsl::windows::wslc
src/windows/wslc/core/Reporter.h
new
+165
@@ -0,0 +1,165 @@
1
+/*++
2
+
3
+Copyright (c) Microsoft. All rights reserved.
4
+
5
+Module Name:
6
+
7
+ Reporter.h
8
+
9
+Abstract:
10
+
11
+ Level-filtered, std::format-style user-facing output for the WSLC CLI.
12
+ Sequence arguments are stripped when VT is off; color Sequences are also
13
+ stripped when color is disabled, while cursor-move Sequences still pass
14
+ through.
15
+
16
+--*/
17
+#pragma once
18
+
19
+#include "OutputChannel.h"
20
+#include "VTSupport.h"
21
+
22
+#include <cstdio>
23
+#include <format>
24
+#include <optional>
25
+#include <string>
26
+#include <string_view>
27
+#include <tuple>
28
+#include <type_traits>
29
+#include <utility>
30
+
31
+namespace wsl::windows::wslc {
32
+
33
+namespace reporter_detail {
34
+
35
+ // SFINAE: excludes Sequence-derived types so the overload below wins for them.
36
+ template <typename T, typename = std::enable_if_t<!std::is_base_of_v<wsl::windows::common::vt::Sequence, std::remove_cvref_t<T>>>>
37
+ constexpr T&& StripIfDisabled(T&& value, bool /*vtEnabled*/, bool /*colorEnabled*/) noexcept
38
+ {
39
+ return std::forward<T>(value);
40
+ }
41
+
42
+ // Returns VT bytes when permitted, empty when stripped. The returned view borrows
43
+ // from the caller's argument, which outlives the Write call.
44
+ inline std::wstring_view StripIfDisabled(const wsl::windows::common::vt::Sequence& sequence, bool vtEnabled, bool colorEnabled)
45
+ {
46
+ if (!vtEnabled)
47
+ {
48
+ return {};
49
+ }
50
+ if (!colorEnabled && sequence.IsColor())
51
+ {
52
+ return {};
53
+ }
54
+ return sequence.Get();
55
+ }
56
+
57
+} // namespace reporter_detail
58
+
59
+struct Reporter
60
+{
61
+ enum class Level
62
+ {
63
+ Output,
64
+ Info,
65
+ Warning,
66
+ Error,
67
+ };
68
+
69
+ Reporter();
70
+ Reporter(FILE* outFile, bool outVtEnabled, FILE* errFile, bool errVtEnabled);
71
+
72
+ NON_COPYABLE(Reporter);
73
+ NON_MOVABLE(Reporter);
74
+
75
+ ~Reporter() = default;
76
+
77
+ // std::format-style write API.
78
+ template <typename... Args>
79
+ void Write(Level level, std::wformat_string<Args...> fmt, Args&&... args)
80
+ {
81
+ EmitFormatted(level, std::move(fmt), std::forward<Args>(args)...);
82
+ }
83
+
84
+ template <typename... Args>
85
+ void Output(std::wformat_string<Args...> fmt, Args&&... args)
86
+ {
87
+ EmitFormatted(Level::Output, std::move(fmt), std::forward<Args>(args)...);
88
+ }
89
+ template <typename... Args>
90
+ void Info(std::wformat_string<Args...> fmt, Args&&... args)
91
+ {
92
+ EmitFormatted(Level::Info, std::move(fmt), std::forward<Args>(args)...);
93
+ }
94
+ template <typename... Args>
95
+ void Warn(std::wformat_string<Args...> fmt, Args&&... args)
96
+ {
97
+ EmitFormatted(Level::Warning, std::move(fmt), std::forward<Args>(args)...);
98
+ }
99
+ template <typename... Args>
100
+ void Error(std::wformat_string<Args...> fmt, Args&&... args)
101
+ {
102
+ EmitFormatted(Level::Error, std::move(fmt), std::forward<Args>(args)...);
103
+ }
104
+
105
+ bool IsVTEnabled(Level level) const noexcept;
106
+
107
+ bool IsColorEnabled(Level level) const noexcept;
108
+
109
+ bool IsNoColor() const noexcept
110
+ {
111
+ return m_noColor;
112
+ }
113
+
114
+ void SetNoColor(bool noColor) noexcept
115
+ {
116
+ m_noColor = noColor;
117
+ }
118
+
119
+ // Console write width minus one (autowrap guard), or nullopt when redirected.
120
+ std::optional<int> GetConsoleWidth(Level level) const;
121
+
122
+private:
123
+ const OutputChannel& ChannelFor(Level level) const noexcept
124
+ {
125
+ return (level == Level::Output) ? m_out : m_err;
126
+ }
127
+
128
+ // Per-level SGR prefix (empty when color is off).
129
+ std::wstring_view LevelPrefix(Level level) const noexcept;
130
+
131
+ template <typename... Args>
132
+ void EmitFormatted(Level level, std::wformat_string<Args...> fmt, Args&&... args)
133
+ {
134
+ const OutputChannel& channel = ChannelFor(level);
135
+ const bool vtEnabled = channel.IsVTEnabled();
136
+ const bool colorEnabled = vtEnabled && !m_noColor;
137
+
138
+ // Materialize stripped args into stable storage for vformat.
139
+ auto stripped = std::tuple{reporter_detail::StripIfDisabled(std::forward<Args>(args), vtEnabled, colorEnabled)...};
140
+
141
+ std::wstring body = std::apply(
142
+ [&fmt](auto&... values) { return std::vformat(std::wstring_view{fmt.get()}, std::make_wformat_args(values...)); }, stripped);
143
+
144
+ const auto prefix = LevelPrefix(level);
145
+ if (prefix.empty())
146
+ {
147
+ channel.WriteString(body);
148
+ return;
149
+ }
150
+
151
+ const auto reset = wsl::windows::common::vt::Format::Default.Get();
152
+ std::wstring out;
153
+ out.reserve(prefix.size() + body.size() + reset.size());
154
+ out.append(prefix);
155
+ out.append(body);
156
+ out.append(reset);
157
+ channel.WriteString(out);
158
+ }
159
+
160
+ OutputChannel m_out;
161
+ OutputChannel m_err;
162
+ bool m_noColor = false;
163
+};
164
+
165
+} // namespace wsl::windows::wslc
test/windows/wslc/WSLCCLIReporterUnitTests.cpp
new
+314
@@ -0,0 +1,314 @@
1
+/*++
2
+
3
+Copyright (c) Microsoft. All rights reserved.
4
+
5
+Module Name:
6
+
7
+ WSLCCLIReporterUnitTests.cpp
8
+
9
+Abstract:
10
+
11
+ Unit tests for OutputChannel and Reporter.
12
+
13
+--*/
14
+
15
+#include "precomp.h"
16
+#include "windows/Common.h"
17
+#include "WSLCCLITestHelpers.h"
18
+
19
+#include "OutputChannel.h"
20
+#include "Reporter.h"
21
+
22
+using namespace wsl::windows::wslc;
23
+using namespace wsl::windows::common::vt;
24
+using namespace WSLCTestHelpers;
25
+using namespace WEX::Logging;
26
+using namespace WEX::Common;
27
+using namespace WEX::TestExecution;
28
+
29
+namespace WSLCCLIReporterUnitTests {
30
+
31
+// Dual-pipe Reporter so stdout and stderr can be asserted independently.
32
+struct SplitCaptureReporter
33
+{
34
+ CapturePipe outPipe;
35
+ CapturePipe errPipe;
36
+ Reporter reporter;
37
+
38
+ explicit SplitCaptureReporter(bool vtEnabled = false) : reporter(outPipe.file(), vtEnabled, errPipe.file(), vtEnabled)
39
+ {
40
+ }
41
+};
42
+
43
+class WSLCCLIReporterUnitTests
44
+{
45
+ WSLC_TEST_CLASS(WSLCCLIReporterUnitTests)
46
+
47
+ TEST_CLASS_SETUP(TestClassSetup)
48
+ {
49
+ return true;
50
+ }
51
+
52
+ TEST_CLASS_CLEANUP(TestClassCleanup)
53
+ {
54
+ return true;
55
+ }
56
+
57
+ TEST_METHOD(OutputChannel_WriteStringWritesText)
58
+ {
59
+ CapturePipe pipe;
60
+ const OutputChannel channel{pipe.file(), false};
61
+ channel.WriteString(L"hello");
62
+ VERIFY_ARE_EQUAL(std::wstring{L"hello"}, pipe.captured());
63
+ }
64
+
65
+ TEST_METHOD(OutputChannel_WriteStringIsNoOpOnEmpty)
66
+ {
67
+ CapturePipe pipe;
68
+ const OutputChannel channel{pipe.file(), false};
69
+ channel.WriteString(L"");
70
+ VERIFY_ARE_EQUAL(std::wstring{L""}, pipe.captured());
71
+ }
72
+
73
+ TEST_METHOD(OutputChannel_FromHandleFallsBackToFileForNonConsole)
74
+ {
75
+ CapturePipe pipe;
76
+ const OutputChannel channel{INVALID_HANDLE_VALUE, pipe.file()};
77
+ channel.WriteString(L"fallback");
78
+ VERIFY_ARE_EQUAL(std::wstring{L"fallback"}, pipe.captured());
79
+ VERIFY_IS_FALSE(channel.GetConsoleWidth().has_value());
80
+ }
81
+
82
+ TEST_METHOD(OutputChannel_GetConsoleWidth_FileChannelReturnsNullopt)
83
+ {
84
+ CapturePipe pipe;
85
+ const OutputChannel channel{pipe.file(), false};
86
+ VERIFY_IS_FALSE(channel.GetConsoleWidth().has_value());
87
+ }
88
+
89
+ TEST_METHOD(Reporter_WriteEmitsExactText)
90
+ {
91
+ CaptureReporter cap;
92
+ cap.reporter.Output(L"hello\n");
93
+ VERIFY_ARE_EQUAL(std::wstring{L"hello\n"}, cap.captured());
94
+ }
95
+
96
+ TEST_METHOD(Reporter_WriteWithoutNewline)
97
+ {
98
+ CaptureReporter cap;
99
+ cap.reporter.Write(Reporter::Level::Output, L"hello");
100
+ VERIFY_ARE_EQUAL(std::wstring{L"hello"}, cap.captured());
101
+ }
102
+
103
+ TEST_METHOD(Reporter_FormatStringSubstitutesArgs)
104
+ {
105
+ CaptureReporter cap;
106
+ cap.reporter.Output(L"value={}, name={}\n", 42, L"alice");
107
+ VERIFY_ARE_EQUAL(std::wstring{L"value=42, name=alice\n"}, cap.captured());
108
+ }
109
+
110
+ TEST_METHOD(Reporter_PlainStringNeedsNoArgs)
111
+ {
112
+ CaptureReporter cap;
113
+ cap.reporter.Output(L"plain literal\n");
114
+ VERIFY_ARE_EQUAL(std::wstring{L"plain literal\n"}, cap.captured());
115
+ }
116
+
117
+ TEST_METHOD(Reporter_SequenceEmittedWhenVTEnabled)
118
+ {
119
+ CaptureReporter cap{/*vtEnabled*/ true};
120
+ cap.reporter.Output(L"{}highlighted{}\n", Format::Fg::BrightYellow, Format::Default);
121
+
122
+ const auto result = cap.captured();
123
+ VERIFY_ARE_NOT_EQUAL(std::wstring::npos, result.find(L"highlighted"));
124
+ VERIFY_ARE_NOT_EQUAL(std::wstring::npos, result.find(Format::Fg::BrightYellow.Get()));
125
+ }
126
+
127
+ TEST_METHOD(Reporter_SequenceStrippedWhenVTDisabled)
128
+ {
129
+ CaptureReporter cap{/*vtEnabled*/ false};
130
+ cap.reporter.Output(L"{}plain{}\n", Format::Fg::BrightYellow, Format::Default);
131
+ VERIFY_ARE_EQUAL(std::wstring{L"plain\n"}, cap.captured());
132
+ }
133
+
134
+ TEST_METHOD(Reporter_ColorSequenceStrippedWhenNoColor)
135
+ {
136
+ CaptureReporter cap{/*vtEnabled*/ true};
137
+ cap.reporter.SetNoColor(true);
138
+
139
+ // Color sequence (SGR) stripped; cursor moves (non-color) still pass.
140
+ cap.reporter.Output(L"{}{}plain{}\n", Cursor::Up(1), Format::Fg::BrightRed, Format::Default);
141
+
142
+ const auto result = cap.captured();
143
+ VERIFY_ARE_EQUAL(std::wstring::npos, result.find(Format::Fg::BrightRed.Get()));
144
+ VERIFY_ARE_NOT_EQUAL(std::wstring::npos, result.find(Cursor::Up(1).Get()));
145
+ VERIFY_ARE_NOT_EQUAL(std::wstring::npos, result.find(L"plain"));
146
+ }
147
+
148
+ TEST_METHOD(Reporter_ConstructedSequenceHandledLikeSequence)
149
+ {
150
+ CaptureReporter cap{/*vtEnabled*/ true};
151
+ const auto cursor = Cursor::Up(3);
152
+ cap.reporter.Output(L"{}done\n", cursor);
153
+
154
+ const auto result = cap.captured();
155
+ VERIFY_ARE_NOT_EQUAL(std::wstring::npos, result.find(cursor.Get()));
156
+ VERIFY_ARE_NOT_EQUAL(std::wstring::npos, result.find(L"done"));
157
+ }
158
+
159
+ TEST_METHOD(Reporter_LevelColorWrapsOutputWhenVTEnabled)
160
+ {
161
+ CaptureReporter cap{/*vtEnabled*/ true};
162
+
163
+ cap.reporter.Output(L"starting\n");
164
+ cap.reporter.Info(L"pulling\n");
165
+ cap.reporter.Warn(L"careful\n");
166
+ cap.reporter.Error(L"failed\n");
167
+
168
+ const std::wstring def{Format::Default.Get()};
169
+ const std::wstring yellow{Format::Fg::BrightYellow.Get()};
170
+ const std::wstring red{Format::Fg::BrightRed.Get()};
171
+
172
+ const auto expected = std::wstring{L"starting\npulling\n"} + yellow + L"careful\n" + def + red + L"failed\n" + def;
173
+
174
+ VERIFY_ARE_EQUAL(expected, cap.captured());
175
+ }
176
+
177
+ TEST_METHOD(Reporter_LevelColorSuppressedWhenVTDisabled)
178
+ {
179
+ CaptureReporter cap{/*vtEnabled*/ false};
180
+ cap.reporter.Error(L"failed\n");
181
+ VERIFY_ARE_EQUAL(std::wstring{L"failed\n"}, cap.captured());
182
+ }
183
+
184
+ TEST_METHOD(Reporter_LevelColorSuppressedWhenNoColor)
185
+ {
186
+ CaptureReporter cap{/*vtEnabled*/ true};
187
+ cap.reporter.SetNoColor(true);
188
+ cap.reporter.Warn(L"careful\n");
189
+ VERIFY_ARE_EQUAL(std::wstring{L"careful\n"}, cap.captured());
190
+ }
191
+
192
+ TEST_METHOD(Reporter_RoutingByLevel)
193
+ {
194
+ SplitCaptureReporter cap;
195
+
196
+ cap.reporter.Output(L"output text\n");
197
+ cap.reporter.Info(L"info text\n");
198
+ cap.reporter.Warn(L"warn text\n");
199
+ cap.reporter.Error(L"error text\n");
200
+
201
+ VERIFY_ARE_EQUAL(std::wstring{L"output text\n"}, cap.outPipe.captured());
202
+ VERIFY_ARE_EQUAL(std::wstring{L"info text\nwarn text\nerror text\n"}, cap.errPipe.captured());
203
+ }
204
+
205
+ TEST_METHOD(Reporter_SetNoColorTogglesIsNoColor)
206
+ {
207
+ CaptureReporter cap;
208
+ VERIFY_IS_FALSE(cap.reporter.IsNoColor());
209
+ cap.reporter.SetNoColor(true);
210
+ VERIFY_IS_TRUE(cap.reporter.IsNoColor());
211
+ cap.reporter.SetNoColor(false);
212
+ VERIFY_IS_FALSE(cap.reporter.IsNoColor());
213
+ }
214
+
215
+ TEST_METHOD(Reporter_IsVTEnabledReflectsPerChannelState)
216
+ {
217
+ {
218
+ SplitCaptureReporter cap{/*vt*/ false};
219
+ VERIFY_IS_FALSE(cap.reporter.IsVTEnabled(Reporter::Level::Output));
220
+ VERIFY_IS_FALSE(cap.reporter.IsVTEnabled(Reporter::Level::Error));
221
+ }
222
+ {
223
+ SplitCaptureReporter cap{/*vt*/ true};
224
+ VERIFY_IS_TRUE(cap.reporter.IsVTEnabled(Reporter::Level::Output));
225
+ VERIFY_IS_TRUE(cap.reporter.IsVTEnabled(Reporter::Level::Error));
226
+ }
227
+ {
228
+ CapturePipe outPipe;
229
+ CapturePipe errPipe;
230
+ Reporter reporter{outPipe.file(), /*outVt*/ true, errPipe.file(), /*errVt*/ false};
231
+ VERIFY_IS_TRUE(reporter.IsVTEnabled(Reporter::Level::Output));
232
+ VERIFY_IS_FALSE(reporter.IsVTEnabled(Reporter::Level::Info));
233
+ VERIFY_IS_FALSE(reporter.IsVTEnabled(Reporter::Level::Warning));
234
+ VERIFY_IS_FALSE(reporter.IsVTEnabled(Reporter::Level::Error));
235
+ }
236
+ }
237
+
238
+ TEST_METHOD(Reporter_IsColorEnabledPerLevelHonorsBothVTAndNoColor)
239
+ {
240
+ SplitCaptureReporter cap{/*vt*/ true};
241
+ VERIFY_IS_TRUE(cap.reporter.IsColorEnabled(Reporter::Level::Output));
242
+ VERIFY_IS_TRUE(cap.reporter.IsColorEnabled(Reporter::Level::Error));
243
+
244
+ cap.reporter.SetNoColor(true);
245
+ VERIFY_IS_FALSE(cap.reporter.IsColorEnabled(Reporter::Level::Output));
246
+ VERIFY_IS_FALSE(cap.reporter.IsColorEnabled(Reporter::Level::Error));
247
+ }
248
+
249
+ TEST_METHOD(Reporter_GetConsoleWidthReturnsNulloptForFileChannels)
250
+ {
251
+ SplitCaptureReporter cap;
252
+ VERIFY_IS_FALSE(cap.reporter.GetConsoleWidth(Reporter::Level::Output).has_value());
253
+ VERIFY_IS_FALSE(cap.reporter.GetConsoleWidth(Reporter::Level::Info).has_value());
254
+ VERIFY_IS_FALSE(cap.reporter.GetConsoleWidth(Reporter::Level::Warning).has_value());
255
+ VERIFY_IS_FALSE(cap.reporter.GetConsoleWidth(Reporter::Level::Error).has_value());
256
+ }
257
+
258
+ TEST_METHOD(Reporter_Write_MixesSequencesWithStandardFormatArgs)
259
+ {
260
+ // Reporter.Write is std::format under the hood — any formattable type works
261
+ // alongside Sequences. Sequences are stripped when color is off; everything
262
+ // else formats normally through std::format machinery.
263
+ //
264
+ // This test exercises four sequence categories in a single format call:
265
+ // SGR color (Format::Fg::BrightRed) — color, stripped by NoColor
266
+ // Non-color (Erase::LineForward) — not color, survives NoColor
267
+ // Hyperlink (ConstructedSequence OSC8) — color, stripped by NoColor
268
+ // SGR reset (Format::Default) — color, stripped by NoColor
269
+ //
270
+ // Hyperlink open/close are separate sequences so the visible link text
271
+ // degrades gracefully when sequences are stripped.
272
+
273
+ const auto& eraseLine = Erase::LineForward; // \x1b[K — non-color CSI
274
+ const auto linkOpen = Format::LinkOpen(L"https://example.com");
275
+ const auto& linkClose = Format::LinkClose;
276
+
277
+ // Format: <color>Count: <int>, hex: <hex>, <erase><linkOpen>click here<linkClose><reset>
278
+ constexpr auto fmt = L"{}Count: {}, hex: {:04x}, {}{}click here{}{}\n";
279
+
280
+ // VT + color enabled: equivalent to std::format with all sequence bytes.
281
+ {
282
+ CaptureReporter cap{/*vtEnabled*/ true};
283
+ cap.reporter.Output(fmt, Format::Fg::BrightRed, 42, 255u, eraseLine, linkOpen, linkClose, Format::Default);
284
+
285
+ const auto expected = std::format(
286
+ fmt, Format::Fg::BrightRed.Get(), 42, 255u, eraseLine.Get(), linkOpen.Get(), linkClose.Get(), Format::Default.Get());
287
+ VERIFY_ARE_EQUAL(expected, cap.captured());
288
+ }
289
+
290
+ // NoColor (VT enabled, color disabled): non-color sequences pass through,
291
+ // color sequences (SGR, hyperlink) replaced with empty string.
292
+ {
293
+ CaptureReporter cap{/*vtEnabled*/ true};
294
+ cap.reporter.SetNoColor(true);
295
+ cap.reporter.Output(fmt, Format::Fg::BrightRed, 42, 255u, eraseLine, linkOpen, linkClose, Format::Default);
296
+
297
+ const std::wstring_view empty;
298
+ const auto expected = std::format(fmt, empty, 42, 255u, eraseLine.Get(), empty, empty, empty);
299
+ VERIFY_ARE_EQUAL(expected, cap.captured());
300
+ }
301
+
302
+ // VT disabled: all sequences replaced with empty string.
303
+ {
304
+ CaptureReporter cap{/*vtEnabled*/ false};
305
+ cap.reporter.Output(fmt, Format::Fg::BrightRed, 42, 255u, eraseLine, linkOpen, linkClose, Format::Default);
306
+
307
+ const std::wstring_view empty;
308
+ const auto expected = std::format(fmt, empty, 42, 255u, empty, empty, empty, empty);
309
+ VERIFY_ARE_EQUAL(expected, cap.captured());
310
+ }
311
+ }
312
+};
313
+
314
+} // namespace WSLCCLIReporterUnitTests
test/windows/wslc/WSLCCLITestHelpers.h
+85
@@ -14,10 +14,20 @@ Abstract:
14
15
#pragma once
16
17
+#include <fcntl.h>
18
+#include <io.h>
19
+#include <algorithm>
20
+#include <memory>
21
#include <string>
22
+#include <vector>
23
#include <Windows.h>
24
#include <WexTestClass.h>
25
+#include <wil/resource.h>
26
+#include <wslutil.h>
27
+#include "windows/Common.h"
28
#include "Invocation.h"
29
+#include "OutputChannel.h"
30
+#include "Reporter.h"
31
#include "TableOutput.h"
32
33
namespace WSLCTestHelpers {
@@ -62,6 +72,81 @@ inline void LogComment(const std::wstring& message)
72
WEX::Logging::Log::Comment(reinterpret_cast<const char8_t*>(WStringToUTF8(message).c_str()));
73
}
74
75
+// RAII pipe pair for capturing FILE* output in tests.
76
+// file() is passed to OutputChannel/Reporter; captured() drains the read end after flush.
77
+struct CapturePipe
78
+{
79
+ CapturePipe()
80
+ {
81
+ // ReadPipeOverlapped=true so PartialHandleRead's InterruptableRead can be
82
+ // interrupted by m_exitEvent during teardown if fclose hasn't run yet.
83
+ auto [r, w] = wsl::windows::common::wslutil::OpenAnonymousPipe(0, true, false);
84
+ wil::unique_handle writeHandle{w.release()};
85
+ m_file = FileFromHandle(writeHandle, "w");
86
+
87
+ const int fd = _fileno(m_file.get());
88
+ WI_VERIFY(_setmode(fd, _O_U8TEXT) != -1);
89
+
90
+ // Disable CRT buffering so each fwprintf is a single write. Prevents
91
+ // _O_U8TEXT from splitting VT escape sequences across buffer flushes.
92
+ setvbuf(m_file.get(), nullptr, _IONBF, 0);
93
+
94
+ // CapturePipe owns the read pipe; PartialHandleRead borrows it via .get().
95
+ // m_readPipe is declared before m_reader so destruction order tears the reader
96
+ // down first (joining its thread) and only then closes the handle it was reading.
97
+ m_readPipe = std::move(r);
98
+ m_reader = std::make_unique<PartialHandleRead>(m_readPipe.get());
99
+ }
100
+
101
+ NON_COPYABLE(CapturePipe);
102
+ NON_MOVABLE(CapturePipe);
103
+
104
+ FILE* file() const
105
+ {
106
+ return m_file.get();
107
+ }
108
+
109
+ std::wstring captured()
110
+ {
111
+ m_file.reset();
112
+
113
+ m_reader->ExpectClosed();
114
+ std::wstring result = wsl::shared::string::MultiByteToWide(m_reader->GetData());
115
+
116
+ // _O_U8TEXT prepends a UTF-8 BOM on some streams; strip it if present.
117
+ if (!result.empty() && result[0] == L'\xFEFF')
118
+ {
119
+ result.erase(0, 1);
120
+ }
121
+
122
+ // _O_U8TEXT translates \n to \r\n; strip \r so tests compare plain newlines.
123
+ result.erase(std::remove(result.begin(), result.end(), L'\r'), result.end());
124
+ return result;
125
+ }
126
+
127
+private:
128
+ wil::unique_file m_file;
129
+ wil::unique_hfile m_readPipe;
130
+ std::unique_ptr<PartialHandleRead> m_reader;
131
+};
132
+
133
+// Reporter wired to a single capture pipe for full output capture.
134
+// VT is disabled (not a console handle), so error output stays in the same pipe.
135
+struct CaptureReporter
136
+{
137
+ CapturePipe pipe;
138
+ wsl::windows::wslc::Reporter reporter;
139
+
140
+ explicit CaptureReporter(bool vtEnabled = false) : reporter(pipe.file(), vtEnabled, pipe.file(), vtEnabled)
141
+ {
142
+ }
143
+
144
+ std::wstring captured()
145
+ {
146
+ return pipe.captured();
147
+ }
148
+};
149
+
150
// Helper: capture all lines emitted by a TableOutput into a vector<wstring>.
151
template <size_t N>
152
struct TableOutputCapture