master
cpp 808 lines 32.8 KB
Raw
1 /*++
2
3 Copyright (c) Microsoft. All rights reserved.
4
5 Module Name:
6
7 WSLCCLIParserUnitTests.cpp
8
9 Abstract:
10
11 This file contains unit tests for WSLC CLI argument parsing and validation.
12
13 --*/
14
15 #include "precomp.h"
16 #include "windows/Common.h"
17 #include "WSLCCLITestHelpers.h"
18
19 #include "Argument.h"
20 #include "ArgumentConvertedTypes.h"
21 #include "ArgMap.h"
22 #include "ArgumentParser.h"
23 #include "Invocation.h"
24 #include "ParserTestCases.h"
25
26 using namespace wsl::windows::wslc;
27 using namespace wsl::windows::wslc::argument;
28
29 using namespace WSLCTestHelpers;
30 using namespace WEX::Logging;
31 using namespace WEX::Common;
32 using namespace WEX::TestExecution;
33
34 namespace WSLCCLIParserUnitTests {
35
36 using Argument = wsl::windows::wslc::Argument;
37
38 class WSLCCLIParserUnitTests
39 {
40 WSLC_TEST_CLASS(WSLCCLIParserUnitTests)
41
42 TEST_CLASS_SETUP(TestClassSetup)
43 {
44 return true;
45 }
46
47 TEST_CLASS_CLEANUP(TestClassCleanup)
48 {
49 return true;
50 }
51
52 TEST_METHOD(ParserTest_ParserCases)
53 {
54 std::vector<ParserTestCase> testCases = {
55 #define WSLC_PARSER_TEST_CASE(argSetValue, expected, cmdLine) {ArgumentSet::argSetValue, expected, cmdLine},
56 WSLC_PARSER_TEST_CASES
57 #undef WSLC_PARSER_TEST_CASE
58 };
59
60 for (const auto& testCase : testCases)
61 {
62 bool succeeded = false;
63 const bool optionsOnly = IsOptionsOnlySet(testCase.argumentSet);
64
65 try
66 {
67 Log::Comment(String().Format(L"Testing: %ls", testCase.commandLine.c_str()));
68 auto inv = WSLCTestHelpers::CreateInvocationFromCommandLine(testCase.commandLine);
69
70 std::vector<Argument> definedArgs = GetArgumentsForSet(testCase.argumentSet);
71
72 ArgMap args;
73 ParseArgumentsStateMachine stateMachine{inv, args, std::move(definedArgs), optionsOnly};
74 while (stateMachine.Step())
75 {
76 stateMachine.ThrowIfError();
77 }
78 // Step() returns false on stop in optionsOnly mode without surfacing any
79 // pending error, so drain once more to convert "missing value at EOF"
80 // into a thrown ArgumentException.
81 stateMachine.ThrowIfError();
82
83 if (!args.Contains(ArgType::Help))
84 {
85 for (const auto& arg : GetArgumentsForSet(testCase.argumentSet))
86 {
87 // Required-arg enforcement is a *whole-command-line* concern.
88 // In optionsOnly mode the parser only saw part of the input, so
89 // missing values would be reported by the second (subcommand)
90 // pass, not this one. Skip required checks for those sets.
91 if (!optionsOnly && arg.Required() && !args.Contains(arg.Type()))
92 {
93 throw ArgumentException(std::wstring(L"Required argument missing: ") + arg.Name());
94 }
95
96 if (args.Contains(arg.Type()))
97 {
98 arg.Validate(args);
99 }
100 }
101 }
102
103 succeeded = true;
104
105 if (testCase.commandLine.find(L"image1") != std::wstring::npos && testCase.argumentSet == ArgumentSet::Run)
106 {
107 VERIFY_IS_TRUE(args.Contains(ArgType::ImageId));
108 auto imageId = args.GetValue<ArgType::ImageId>();
109 VERIFY_ARE_EQUAL(L"image1", imageId);
110 }
111
112 if (testCase.commandLine.find(L"cont1") != std::wstring::npos && testCase.argumentSet == ArgumentSet::List)
113 {
114 VERIFY_IS_TRUE(args.Contains(ArgType::ContainerId));
115 auto containerId = args.GetValue<ArgType::ContainerId>();
116 VERIFY_ARE_EQUAL(L"cont1", containerId);
117 }
118
119 if (testCase.commandLine.find(L"--rm") != std::wstring::npos)
120 {
121 VERIFY_IS_TRUE(args.Contains(ArgType::Remove));
122 }
123
124 if (testCase.commandLine.find(L"command") != std::wstring::npos && testCase.argumentSet == ArgumentSet::Run)
125 {
126 VERIFY_IS_TRUE(args.Contains(ArgType::Command));
127 auto command = args.GetValue<ArgType::Command>();
128 VERIFY_IS_TRUE(command.find(L"command") != std::wstring::npos);
129 }
130
131 if (testCase.commandLine.find(L"forward") != std::wstring::npos && testCase.argumentSet == ArgumentSet::Run)
132 {
133 VERIFY_IS_TRUE(args.Contains(ArgType::ForwardArgs));
134 auto forwardArgs = args.GetValue<ArgType::ForwardArgs>();
135 std::wstring forwardArgsConcat = wsl::shared::string::Join(forwardArgs, L' ');
136 VERIFY_IS_TRUE(forwardArgsConcat.find(L"hello world") != std::wstring::npos); // Forward args should contain hello world
137 VERIFY_IS_TRUE(forwardArgsConcat.find(L"image1") == std::wstring::npos); // Forward args should not contain the imageId
138 VERIFY_IS_TRUE(forwardArgsConcat.find(L"command") == std::wstring::npos); // Forward args should not contain the command
139 LogComment(L"Forwarded Args: " + forwardArgsConcat);
140 }
141
142 if (testCase.commandLine.find(L"443") != std::wstring::npos)
143 {
144 VERIFY_IS_TRUE(args.Contains(ArgType::Publish));
145 auto publishArgs = args.GetAllValues<ArgType::Publish>();
146 VERIFY_ARE_EQUAL(2, publishArgs.size()); // Should have both publish args
147 VERIFY_ARE_NOT_EQUAL(publishArgs[0], publishArgs[1]); // Both publish args should be different
148 }
149
150 // Globals-specific spot checks: the synthetic global flag
151 // (--quiet / -q) and value (--session, see GetArgumentsForSet)
152 // must have been consumed by the global parser when present,
153 // and the stop position must point at the first non-global
154 // token (or end()).
155 if (testCase.argumentSet == ArgumentSet::Globals)
156 {
157 if (testCase.commandLine.find(L"--quiet") != std::wstring::npos || testCase.commandLine.find(L"-q") != std::wstring::npos)
158 {
159 VERIFY_IS_TRUE(args.Contains(ArgType::Quiet));
160 }
161
162 if (testCase.commandLine.find(L"--session") != std::wstring::npos)
163 {
164 VERIFY_IS_TRUE(args.Contains(ArgType::Session));
165 VERIFY_ARE_EQUAL(std::wstring(L"foo"), args.GetValue<ArgType::Session>());
166 }
167 }
168 }
169 catch (ArgumentException& ex)
170 {
171 if (testCase.expectedResult)
172 {
173 VERIFY_FAIL(String().Format(L"Test case threw unexpected argument exception: %ls", ex.Message().c_str()));
174 }
175 else
176 {
177 Log::Comment(String().Format(L"Test case threw expected argument exception: %ls", ex.Message().c_str()));
178 }
179 }
180 catch (std::exception& ex)
181 {
182 if (testCase.expectedResult)
183 {
184 VERIFY_FAIL(String().Format(L"Test case threw unexpected exception: %hs", ex.what()));
185 }
186 else
187 {
188 Log::Comment(String().Format(L"Test case threw expected exception: %hs", ex.what()));
189 }
190 }
191
192 VERIFY_ARE_EQUAL(testCase.expectedResult, succeeded, String().Format(L"Command line: %ls", testCase.commandLine.c_str()));
193 }
194 }
195
196 // Options-only mode: parser consumes leading options and stops at the first
197 // positional without consuming it. inv must advance past consumed options.
198 TEST_METHOD(OptionsOnly_StopsAtFirstPositional)
199 {
200 auto inv = WSLCTestHelpers::CreateInvocationFromCommandLine(L"wslc --verbose image1 command");
201
202 std::vector<Argument> defs = {
203 Argument::Create(ArgType::Verbose),
204 Argument::Create(ArgType::NoColor),
205 };
206
207 ArgMap args;
208 ParseArgumentsStateMachine sm{inv, args, std::move(defs), /*optionsOnly*/ true};
209 while (sm.Step())
210 {
211 sm.ThrowIfError();
212 }
213
214 VERIFY_IS_TRUE(args.Contains(ArgType::Verbose));
215 VERIFY_IS_FALSE(args.Contains(ArgType::NoColor));
216
217 // Position points at the first positional ("image1") so the next pass
218 // can resume from there via Invocation::consumeUntil.
219 auto pos = sm.Position();
220 VERIFY_IS_TRUE(pos != inv.end());
221 VERIFY_ARE_EQUAL(std::wstring(L"image1"), *pos);
222 }
223
224 TEST_METHOD(OptionsOnly_NoOptionsPresent_StopsImmediately)
225 {
226 auto inv = WSLCTestHelpers::CreateInvocationFromCommandLine(L"wslc image1");
227
228 std::vector<Argument> defs = {Argument::Create(ArgType::Verbose)};
229
230 ArgMap args;
231 ParseArgumentsStateMachine sm{inv, args, std::move(defs), /*optionsOnly*/ true};
232 while (sm.Step())
233 {
234 sm.ThrowIfError();
235 }
236
237 VERIFY_IS_FALSE(args.Contains(ArgType::Verbose));
238 VERIFY_ARE_EQUAL(std::wstring(L"image1"), *sm.Position());
239 }
240
241 TEST_METHOD(OptionsOnly_OnlyOptions_PositionEqualsEnd)
242 {
243 auto inv = WSLCTestHelpers::CreateInvocationFromCommandLine(L"wslc --verbose");
244
245 std::vector<Argument> defs = {Argument::Create(ArgType::Verbose)};
246
247 ArgMap args;
248 ParseArgumentsStateMachine sm{inv, args, std::move(defs), /*optionsOnly*/ true};
249 while (sm.Step())
250 {
251 sm.ThrowIfError();
252 }
253
254 VERIFY_IS_TRUE(args.Contains(ArgType::Verbose));
255 VERIFY_IS_TRUE(sm.Position() == inv.end());
256 }
257
258 TEST_METHOD(OptionsOnly_AdjoinedValue_IsConsumed)
259 {
260 auto inv = WSLCTestHelpers::CreateInvocationFromCommandLine(L"wslc --signal=9 image1");
261
262 std::vector<Argument> defs = {Argument::Create(ArgType::Signal)};
263
264 ArgMap args;
265 ParseArgumentsStateMachine sm{inv, args, std::move(defs), /*optionsOnly*/ true};
266 while (sm.Step())
267 {
268 sm.ThrowIfError();
269 }
270
271 VERIFY_IS_TRUE(args.Contains(ArgType::Signal));
272 VERIFY_ARE_EQUAL(WSLCSignalSIGKILL, args.GetValue<ArgType::Signal>());
273 VERIFY_ARE_EQUAL(std::wstring(L"image1"), *sm.Position());
274 }
275
276 TEST_METHOD(OptionsOnly_SeparatedValue_IsConsumed)
277 {
278 auto inv = WSLCTestHelpers::CreateInvocationFromCommandLine(L"wslc --signal 9 image1");
279
280 std::vector<Argument> defs = {Argument::Create(ArgType::Signal)};
281
282 ArgMap args;
283 ParseArgumentsStateMachine sm{inv, args, std::move(defs), /*optionsOnly*/ true};
284 while (sm.Step())
285 {
286 sm.ThrowIfError();
287 }
288
289 VERIFY_IS_TRUE(args.Contains(ArgType::Signal));
290 VERIFY_ARE_EQUAL(WSLCSignalSIGKILL, args.GetValue<ArgType::Signal>());
291 VERIFY_ARE_EQUAL(std::wstring(L"image1"), *sm.Position());
292 }
293
294 TEST_METHOD(OptionsOnly_UnknownOption_Throws)
295 {
296 auto inv = WSLCTestHelpers::CreateInvocationFromCommandLine(L"wslc --doesnotexist image1");
297
298 std::vector<Argument> defs = {Argument::Create(ArgType::Verbose)};
299
300 ArgMap args;
301 bool threw = false;
302 try
303 {
304 ParseArgumentsStateMachine sm{inv, args, std::move(defs), /*optionsOnly*/ true};
305 while (sm.Step())
306 {
307 sm.ThrowIfError();
308 }
309 }
310 catch (const ArgumentException& exception)
311 {
312 threw = true;
313 VERIFY_ARE_EQUAL(wsl::shared::Localization::WSLCCLI_InvalidNameError(L"--doesnotexist"), exception.Message());
314 }
315
316 VERIFY_IS_TRUE(threw);
317 }
318
319 TEST_METHOD(OptionsOnly_ValueAtEndOfInput_Throws)
320 {
321 auto inv = WSLCTestHelpers::CreateInvocationFromCommandLine(L"wslc --signal");
322
323 std::vector<Argument> defs = {Argument::Create(ArgType::Signal)};
324
325 ArgMap args;
326 bool threw = false;
327 try
328 {
329 ParseArgumentsStateMachine sm{inv, args, std::move(defs), /*optionsOnly*/ true};
330 while (sm.Step())
331 {
332 sm.ThrowIfError();
333 }
334 sm.ThrowIfError();
335 }
336 catch (const ArgumentException& exception)
337 {
338 threw = true;
339 VERIFY_ARE_EQUAL(wsl::shared::Localization::WSLCCLI_MissingArgumentError(L"--signal"), exception.Message());
340 }
341
342 VERIFY_IS_TRUE(threw);
343 }
344
345 // After options-only stops, Invocation::consumeUntil(Position()) hands the
346 // remaining tokens to a second parse pass — exactly what Main.cpp does.
347 TEST_METHOD(OptionsOnly_TwoPassParseAcrossPositional)
348 {
349 auto inv = WSLCTestHelpers::CreateInvocationFromCommandLine(L"wslc --verbose image1 --signal 9");
350
351 // Pass 1: options-only with just Verbose visible.
352 std::vector<Argument> globalDefs = {Argument::Create(ArgType::Verbose)};
353 ArgMap globals;
354 ParseArgumentsStateMachine sm1{inv, globals, std::move(globalDefs), /*optionsOnly*/ true};
355 while (sm1.Step())
356 {
357 sm1.ThrowIfError();
358 }
359 inv.consumeUntil(sm1.Position());
360
361 VERIFY_IS_TRUE(globals.Contains(ArgType::Verbose));
362 VERIFY_IS_FALSE(globals.Contains(ArgType::Signal));
363
364 // Pass 2: full mode with the subcommand's argument set.
365 std::vector<Argument> subDefs = {
366 Argument::Create(ArgType::ImageId, {.Required = true}),
367 Argument::Create(ArgType::Signal),
368 };
369 ArgMap subArgs;
370 ParseArgumentsStateMachine sm2{inv, subArgs, std::move(subDefs), /*optionsOnly*/ false};
371 while (sm2.Step())
372 {
373 sm2.ThrowIfError();
374 }
375
376 VERIFY_IS_TRUE(subArgs.Contains(ArgType::ImageId));
377 VERIFY_ARE_EQUAL(std::wstring(L"image1"), subArgs.GetValue<ArgType::ImageId>());
378 VERIFY_IS_TRUE(subArgs.Contains(ArgType::Signal));
379 VERIFY_ARE_EQUAL(WSLCSignalSIGKILL, subArgs.GetValue<ArgType::Signal>());
380 }
381
382 // stopOnUnknown: unknown -alias / --name / lone '-' / bare '--' tokens
383 // back the iterator up and stop cleanly instead of throwing. Recognized
384 // options before the unknown one are still consumed.
385 TEST_METHOD(OptionsOnly_StopOnUnknown_LeavesTokenForCaller)
386 {
387 // Case 1: leading unknown --name. Nothing consumed, position == begin.
388 {
389 auto inv = WSLCTestHelpers::CreateInvocationFromCommandLine(L"wslc --doesnotexist image1");
390 std::vector<Argument> defs = {Argument::Create(ArgType::Verbose)};
391
392 ArgMap args;
393 ParseArgumentsStateMachine sm{inv, args, std::move(defs), /*optionsOnly*/ true, /*stopOnUnknown*/ true};
394 while (sm.Step())
395 {
396 sm.ThrowIfError();
397 }
398 sm.ThrowIfError();
399
400 VERIFY_IS_FALSE(args.Contains(ArgType::Verbose));
401 VERIFY_ARE_EQUAL(std::wstring(L"--doesnotexist"), *sm.Position());
402 }
403
404 // Case 2: leading unknown -alias. Same backing-up behavior.
405 {
406 auto inv = WSLCTestHelpers::CreateInvocationFromCommandLine(L"wslc -X image1");
407 std::vector<Argument> defs = {Argument::Create(ArgType::Verbose)};
408
409 ArgMap args;
410 ParseArgumentsStateMachine sm{inv, args, std::move(defs), /*optionsOnly*/ true, /*stopOnUnknown*/ true};
411 while (sm.Step())
412 {
413 sm.ThrowIfError();
414 }
415 sm.ThrowIfError();
416
417 VERIFY_IS_FALSE(args.Contains(ArgType::Verbose));
418 VERIFY_ARE_EQUAL(std::wstring(L"-X"), *sm.Position());
419 }
420
421 // Case 3: recognized option consumed, then unknown stops the scan.
422 {
423 auto inv = WSLCTestHelpers::CreateInvocationFromCommandLine(L"wslc --verbose --doesnotexist image1");
424 std::vector<Argument> defs = {Argument::Create(ArgType::Verbose)};
425
426 ArgMap args;
427 ParseArgumentsStateMachine sm{inv, args, std::move(defs), /*optionsOnly*/ true, /*stopOnUnknown*/ true};
428 while (sm.Step())
429 {
430 sm.ThrowIfError();
431 }
432 sm.ThrowIfError();
433
434 VERIFY_IS_TRUE(args.Contains(ArgType::Verbose));
435 VERIFY_ARE_EQUAL(std::wstring(L"--doesnotexist"), *sm.Position());
436 }
437
438 // Case 4: lone '-' with no positionals defined backs up instead of erroring.
439 {
440 auto inv = WSLCTestHelpers::CreateInvocationFromCommandLine(L"wslc -");
441 std::vector<Argument> defs = {Argument::Create(ArgType::Verbose)};
442
443 ArgMap args;
444 ParseArgumentsStateMachine sm{inv, args, std::move(defs), /*optionsOnly*/ true, /*stopOnUnknown*/ true};
445 while (sm.Step())
446 {
447 sm.ThrowIfError();
448 }
449 sm.ThrowIfError();
450
451 VERIFY_ARE_EQUAL(std::wstring(L"-"), *sm.Position());
452 }
453 }
454
455 // Preloaded env-style default can be replaced by a single CLI occurrence
456 // even though the arg's Limit is 1.
457 TEST_METHOD(OverridableDefaults_CliValueReplacesPreload)
458 {
459 auto inv = WSLCTestHelpers::CreateInvocationFromCommandLine(L"wslc --signal 9");
460
461 std::vector<Argument> defs = {Argument::Create(ArgType::Signal)};
462
463 ArgMap args;
464 args.Add(ArgType::Signal, std::wstring(L"15")); // pretend env preloaded SIGTERM
465
466 ParseArgumentsStateMachine sm{inv, args, defs, /*optionsOnly*/ false, /*stopOnUnknown*/ false, /*overridableDefaults*/ defs};
467 while (sm.Step())
468 {
469 sm.ThrowIfError();
470 }
471 sm.ThrowIfError();
472
473 VERIFY_ARE_EQUAL(1u, args.Count(ArgType::Signal));
474 VERIFY_ARE_EQUAL(WSLCSignalSIGKILL, args.GetValue<ArgType::Signal>());
475 }
476
477 // A preloaded (env-style) default followed by multiple CLI values collapses to the
478 // final CLI value: the preload is dropped and single-value args are last-wins.
479 TEST_METHOD(PreloadedDefault_LastCliValueWins)
480 {
481 auto inv = WSLCTestHelpers::CreateInvocationFromCommandLine(L"wslc --signal 9 --signal 1");
482
483 std::vector<Argument> defs = {Argument::Create(ArgType::Signal)};
484
485 ArgMap args;
486 args.Add(ArgType::Signal, std::wstring(L"15"));
487
488 ParseArgumentsStateMachine sm{inv, args, defs, /*optionsOnly*/ false, /*stopOnUnknown*/ false, /*overridableDefaults*/ defs};
489 while (sm.Step())
490 {
491 sm.ThrowIfError();
492 }
493 sm.ThrowIfError();
494
495 VERIFY_ARE_EQUAL(1u, args.Count(ArgType::Signal));
496 VERIFY_ARE_EQUAL(WSLCSignalSIGHUP, args.GetValue<ArgType::Signal>());
497 }
498
499 // Preloaded flag default plus CLI mention of the same flag stays a single
500 // entry (current flag value is always 'true').
501 TEST_METHOD(OverridableDefaults_FlagPreloadCoexistsWithCli)
502 {
503 auto inv = WSLCTestHelpers::CreateInvocationFromCommandLine(L"wslc --verbose");
504
505 std::vector<Argument> defs = {Argument::Create(ArgType::Verbose)};
506
507 ArgMap args;
508 args.Add(ArgType::Verbose, true); // pretend env preloaded it
509
510 ParseArgumentsStateMachine sm{inv, args, defs, /*optionsOnly*/ false, /*stopOnUnknown*/ false, /*overridableDefaults*/ defs};
511 while (sm.Step())
512 {
513 sm.ThrowIfError();
514 }
515 sm.ThrowIfError();
516
517 VERIFY_ARE_EQUAL(1u, args.Count(ArgType::Verbose));
518 VERIFY_IS_TRUE(args.GetValue<ArgType::Verbose>());
519 }
520
521 // Duplicate flag on the CLI (no env preload) folds to one entry: docker-style.
522 TEST_METHOD(DuplicateFlagOnCli_IsIdempotent)
523 {
524 auto inv = WSLCTestHelpers::CreateInvocationFromCommandLine(L"wslc --verbose --verbose");
525
526 std::vector<Argument> defs = {Argument::Create(ArgType::Verbose)};
527
528 ArgMap args;
529 ParseArgumentsStateMachine sm{inv, args, defs};
530 while (sm.Step())
531 {
532 sm.ThrowIfError();
533 }
534 sm.ThrowIfError();
535
536 VERIFY_ARE_EQUAL(1u, args.Count(ArgType::Verbose));
537 VERIFY_IS_TRUE(args.GetValue<ArgType::Verbose>());
538 }
539
540 // Duplicate single-value arg on the CLI (no preload) is last-wins (docker-style):
541 // the final value replaces the earlier one instead of accumulating.
542 TEST_METHOD(DuplicateValueOnCli_LastWins)
543 {
544 auto inv = WSLCTestHelpers::CreateInvocationFromCommandLine(L"wslc --signal 9 --signal 1");
545
546 std::vector<Argument> defs = {Argument::Create(ArgType::Signal)};
547
548 ArgMap args;
549 ParseArgumentsStateMachine sm{inv, args, defs};
550 while (sm.Step())
551 {
552 sm.ThrowIfError();
553 }
554 sm.ThrowIfError();
555
556 VERIFY_ARE_EQUAL(1u, args.Count(ArgType::Signal));
557 VERIFY_ARE_EQUAL(WSLCSignalSIGHUP, args.GetValue<ArgType::Signal>());
558 }
559
560 // Unlimited value args are exempt from last-wins: every CLI occurrence accumulates.
561 TEST_METHOD(UnlimitedValueOnCli_Accumulates)
562 {
563 ArgMap args =
564 ParseFlags(L"wslc --publish 80:80 --publish 443:443", {Argument::Create(ArgType::Publish, {.Limit = Limit::Unlimited})});
565
566 VERIFY_ARE_EQUAL(2u, args.Count(ArgType::Publish));
567 }
568
569 // Boolean flags store their explicit parsed value: present with true or false when the
570 // flag is specified, absent when it is not. Consumers read them via ArgMap::GetValue(defaultValue),
571 // which returns the stored value if present or a caller-supplied default if absent. The
572 // helper parses a single command line against the supplied defs and returns the resulting
573 // ArgMap so each case can assert the stored flag value.
574 static ArgMap ParseFlags(const std::wstring& commandLine, std::vector<Argument> defs)
575 {
576 auto inv = WSLCTestHelpers::CreateInvocationFromCommandLine(commandLine);
577
578 ArgMap args;
579 ParseArgumentsStateMachine sm{inv, args, std::move(defs)};
580 while (sm.Step())
581 {
582 sm.ThrowIfError();
583 }
584 sm.ThrowIfError();
585 return args;
586 }
587
588 // "--flag" and every recognized true form store a single true entry. The single-letter
589 // "t"/"T" forms are Docker-parity extensions enabled for the CLI flag path.
590 TEST_METHOD(Flag_TrueForms_StoreSingleTrueEntry)
591 {
592 for (const auto* cmd :
593 {L"wslc --verbose",
594 L"wslc --verbose=true",
595 L"wslc --verbose=1",
596 L"wslc --verbose=TRUE",
597 L"wslc --verbose=t",
598 L"wslc --verbose=T"})
599 {
600 Log::Comment(String().Format(L"Testing: %ls", cmd));
601 ArgMap args = ParseFlags(cmd, {Argument::Create(ArgType::Verbose)});
602
603 VERIFY_IS_TRUE(args.Contains(ArgType::Verbose));
604 VERIFY_ARE_EQUAL(1u, args.Count(ArgType::Verbose));
605 VERIFY_IS_TRUE(args.GetValue<ArgType::Verbose>());
606 }
607 }
608
609 // Every recognized false form stores the flag present with value false (a docker-style
610 // "--flag=false"), so Contains() is true but GetValue() reports false. The single-letter
611 // "f"/"F" forms are Docker-parity extensions enabled for the CLI flag path.
612 TEST_METHOD(Flag_FalseForms_StoreSingleFalseEntry)
613 {
614 for (const auto* cmd :
615 {L"wslc --verbose=false", L"wslc --verbose=0", L"wslc --verbose=False", L"wslc --verbose=f", L"wslc --verbose=F"})
616 {
617 Log::Comment(String().Format(L"Testing: %ls", cmd));
618 ArgMap args = ParseFlags(cmd, {Argument::Create(ArgType::Verbose)});
619
620 VERIFY_IS_TRUE(args.Contains(ArgType::Verbose));
621 VERIFY_ARE_EQUAL(1u, args.Count(ArgType::Verbose));
622 VERIFY_IS_FALSE(args.GetValue<ArgType::Verbose>());
623 }
624 }
625
626 // A non-boolean adjoined value is an error rather than being silently ignored.
627 TEST_METHOD(Flag_InvalidBoolean_Throws)
628 {
629 auto inv = WSLCTestHelpers::CreateInvocationFromCommandLine(L"wslc --verbose=maybe");
630
631 std::vector<Argument> defs = {Argument::Create(ArgType::Verbose)};
632
633 ArgMap args;
634 ParseArgumentsStateMachine sm{inv, args, std::move(defs)};
635
636 bool threw = false;
637 try
638 {
639 while (sm.Step())
640 {
641 sm.ThrowIfError();
642 }
643 sm.ThrowIfError();
644 }
645 catch (const ArgumentException& exception)
646 {
647 threw = true;
648 VERIFY_ARE_EQUAL(wsl::shared::Localization::WSLCCLI_FlagInvalidBooleanError(L"--verbose=maybe"), exception.Message());
649 }
650
651 VERIFY_IS_TRUE(threw);
652 }
653
654 // Docker parity: a space-separated token after a boolean flag is NOT consumed as the
655 // flag's value; the flag is true and the token becomes the next positional.
656 TEST_METHOD(Flag_SpaceSeparatedValue_StaysPositional)
657 {
658 ArgMap args = ParseFlags(
659 L"wslc --verbose true",
660 {Argument::Create(ArgType::Verbose), Argument::Create(ArgType::ContainerId, {.Limit = Limit::Unlimited})});
661
662 VERIFY_IS_TRUE(args.Contains(ArgType::Verbose));
663 VERIFY_IS_TRUE(args.GetValue<ArgType::Verbose>());
664 VERIFY_ARE_EQUAL(1u, args.Count(ArgType::ContainerId));
665 VERIFY_ARE_EQUAL(std::wstring(L"true"), args.GetValue<ArgType::ContainerId>());
666 }
667
668 // A value argument's alias carries its value exactly like the long name, including the
669 // adjoined form. The inspect family depends on this for docker's `-f json`.
670 TEST_METHOD(Value_AliasCarriesValue)
671 {
672 std::vector<Argument> defs = {Argument::Create(ArgType::InspectFormat), Argument::Create(ArgType::ObjectId, {.Limit = Limit::Unlimited})};
673
674 VERIFY_ARE_EQUAL(wsl::shared::c_jsonCompactIndent, ParseFlags(L"wslc -f json cont1", defs).GetValue<ArgType::InspectFormat>());
675 VERIFY_ARE_EQUAL(wsl::shared::c_jsonCompactIndent, ParseFlags(L"wslc -f=json cont1", defs).GetValue<ArgType::InspectFormat>());
676
677 // The positional still lands where it belongs once the alias has taken its value.
678 auto args = ParseFlags(L"wslc -f json cont1", defs);
679 VERIFY_ARE_EQUAL(1u, args.Count(ArgType::ObjectId));
680 VERIFY_ARE_EQUAL(std::wstring(L"cont1"), args.GetValue<ArgType::ObjectId>());
681 }
682
683 // Alias forms honor adjoined booleans just like the long name.
684 TEST_METHOD(Flag_AliasAdjoinedBoolean)
685 {
686 VERIFY_IS_TRUE(ParseFlags(L"wslc -q", {Argument::Create(ArgType::Quiet)}).GetValue<ArgType::Quiet>());
687 VERIFY_IS_TRUE(ParseFlags(L"wslc -q=true", {Argument::Create(ArgType::Quiet)}).GetValue<ArgType::Quiet>());
688 VERIFY_IS_FALSE(ParseFlags(L"wslc -q=false", {Argument::Create(ArgType::Quiet)}).GetValue<ArgType::Quiet>());
689 }
690
691 TEST_METHOD(AliasOverride_ControlsCommandAlias)
692 {
693 const std::vector<Argument> customAlias{Argument::Create(ArgType::Quiet, {.Alias = L"x"})};
694 VERIFY_IS_TRUE(ParseFlags(L"wslc -x", customAlias).GetValue<ArgType::Quiet>());
695 VERIFY_THROWS(ParseFlags(L"wslc -q", customAlias), ArgumentException);
696
697 const std::vector<Argument> noAlias{Argument::Create(ArgType::Quiet, {.Alias = NO_ALIAS})};
698 VERIFY_IS_TRUE(ParseFlags(L"wslc --quiet", noAlias).GetValue<ArgType::Quiet>());
699 VERIFY_THROWS(ParseFlags(L"wslc -q", noAlias), ArgumentException);
700 }
701
702 TEST_METHOD(NameOverride_ControlsCommandName)
703 {
704 const std::vector<Argument> definitions{Argument::Create(ArgType::Quiet, {.Name = L"silent"})};
705 VERIFY_IS_TRUE(ParseFlags(L"wslc --silent", definitions).GetValue<ArgType::Quiet>());
706 VERIFY_IS_TRUE(ParseFlags(L"wslc -q", definitions).GetValue<ArgType::Quiet>());
707 VERIFY_THROWS(ParseFlags(L"wslc --quiet", definitions), ArgumentException);
708 }
709
710 // Docker-parity single-letter forms ("t"/"T"/"f"/"F") are honored on the alias form too.
711 TEST_METHOD(Flag_AliasShortBooleanForms)
712 {
713 VERIFY_IS_TRUE(ParseFlags(L"wslc -q=t", {Argument::Create(ArgType::Quiet)}).GetValue<ArgType::Quiet>());
714 VERIFY_IS_TRUE(ParseFlags(L"wslc -q=T", {Argument::Create(ArgType::Quiet)}).GetValue<ArgType::Quiet>());
715 VERIFY_IS_FALSE(ParseFlags(L"wslc -q=f", {Argument::Create(ArgType::Quiet)}).GetValue<ArgType::Quiet>());
716 VERIFY_IS_FALSE(ParseFlags(L"wslc -q=F", {Argument::Create(ArgType::Quiet)}).GetValue<ArgType::Quiet>());
717 }
718
719 // An adjoined boolean value may be wrapped in double quotes (e.g. --flag="true"), just like
720 // an adjoined value argument. The quotes are stripped before the boolean is parsed, on both
721 // the named and alias forms.
722 TEST_METHOD(Flag_QuotedAdjoinedBoolean)
723 {
724 VERIFY_IS_TRUE(ParseFlags(L"wslc --verbose=\"true\"", {Argument::Create(ArgType::Verbose)}).GetValue<ArgType::Verbose>());
725 VERIFY_IS_FALSE(ParseFlags(L"wslc --verbose=\"false\"", {Argument::Create(ArgType::Verbose)}).GetValue<ArgType::Verbose>());
726 VERIFY_IS_TRUE(ParseFlags(L"wslc -q=\"true\"", {Argument::Create(ArgType::Quiet)}).GetValue<ArgType::Quiet>());
727 VERIFY_IS_FALSE(ParseFlags(L"wslc -q=\"false\"", {Argument::Create(ArgType::Quiet)}).GetValue<ArgType::Quiet>());
728 VERIFY_IS_TRUE(ParseFlags(L"wslc --verbose=\"t\"", {Argument::Create(ArgType::Verbose)}).GetValue<ArgType::Verbose>());
729 VERIFY_IS_FALSE(ParseFlags(L"wslc --verbose=\"f\"", {Argument::Create(ArgType::Verbose)}).GetValue<ArgType::Verbose>());
730 }
731
732 // In an alias chain, leading flags are true and a trailing "=false" turns only the
733 // last flag off.
734 TEST_METHOD(Flag_AliasChain_TrailingFalse)
735 {
736 std::vector<Argument> defs = {Argument::Create(ArgType::Quiet), Argument::Create(ArgType::Interactive)};
737
738 ArgMap all = ParseFlags(L"wslc -qi", defs);
739 VERIFY_IS_TRUE(all.GetValue<ArgType::Quiet>());
740 VERIFY_IS_TRUE(all.GetValue<ArgType::Interactive>());
741
742 ArgMap trailingFalse = ParseFlags(L"wslc -qi=false", defs);
743 VERIFY_IS_TRUE(trailingFalse.GetValue<ArgType::Quiet>());
744 VERIFY_IS_FALSE(trailingFalse.GetValue<ArgType::Interactive>());
745 }
746
747 // Repeated flags are last-wins (matching docker) and never accumulate multiple entries:
748 // "--flag --flag=false" ends up false, the reverse ends up true. The flag is stored either
749 // way (a single entry), so GetValue() reports the winning value.
750 TEST_METHOD(Flag_Repeated_LastWins)
751 {
752 ArgMap trueThenFalse = ParseFlags(L"wslc --verbose --verbose=false", {Argument::Create(ArgType::Verbose)});
753 VERIFY_IS_FALSE(trueThenFalse.GetValue<ArgType::Verbose>());
754 VERIFY_ARE_EQUAL(1u, trueThenFalse.Count(ArgType::Verbose));
755
756 ArgMap falseThenTrue = ParseFlags(L"wslc --verbose=false --verbose", {Argument::Create(ArgType::Verbose)});
757 VERIFY_IS_TRUE(falseThenTrue.GetValue<ArgType::Verbose>());
758 VERIFY_ARE_EQUAL(1u, falseThenTrue.Count(ArgType::Verbose));
759
760 ArgMap duplicateTrue = ParseFlags(L"wslc --verbose --verbose=true", {Argument::Create(ArgType::Verbose)});
761 VERIFY_ARE_EQUAL(1u, duplicateTrue.Count(ArgType::Verbose));
762 }
763
764 // "--flag=false" overrides a preloaded (env-style) default of true, replacing it with a
765 // single stored false rather than leaving a lingering true. GetValue() then reports false.
766 TEST_METHOD(Flag_FalseOverridesPreloadedDefault)
767 {
768 auto inv = WSLCTestHelpers::CreateInvocationFromCommandLine(L"wslc --verbose=false");
769
770 std::vector<Argument> defs = {Argument::Create(ArgType::Verbose)};
771
772 ArgMap args;
773 args.Add(ArgType::Verbose, true); // pretend env preloaded it to true
774
775 ParseArgumentsStateMachine sm{inv, args, defs, /*optionsOnly*/ false, /*stopOnUnknown*/ false, /*overridableDefaults*/ defs};
776 while (sm.Step())
777 {
778 sm.ThrowIfError();
779 }
780 sm.ThrowIfError();
781
782 VERIFY_IS_TRUE(args.Contains(ArgType::Verbose));
783 VERIFY_ARE_EQUAL(1u, args.Count(ArgType::Verbose));
784 VERIFY_IS_FALSE(args.GetValue<ArgType::Verbose>());
785 }
786
787 // A flag whose behavior is on by default is read with GetValue(true): absent yields the
788 // default (true), "--flag=false" yields false, and "--flag" yields true. A bare Contains()
789 // cannot express this: it reports true for both "--flag" and "--flag=false" and false when
790 // the flag is absent, so it distinguishes neither the two stored values nor absent-as-default.
791 TEST_METHOD(Flag_GetValueDefaultTrue_DefaultOnFlag)
792 {
793 std::vector<Argument> defs = {Argument::Create(ArgType::Remove)};
794
795 ArgMap absent = ParseFlags(L"wslc", defs);
796 VERIFY_IS_FALSE(absent.Contains(ArgType::Remove));
797 VERIFY_IS_TRUE(absent.GetValue<ArgType::Remove>(true));
798
799 ArgMap disabled = ParseFlags(L"wslc --rm=false", defs);
800 VERIFY_IS_TRUE(disabled.Contains(ArgType::Remove));
801 VERIFY_IS_FALSE(disabled.GetValue<ArgType::Remove>(true));
802
803 ArgMap enabled = ParseFlags(L"wslc --rm", defs);
804 VERIFY_IS_TRUE(enabled.GetValue<ArgType::Remove>(true));
805 }
806 };
807
808 } // namespace WSLCCLIParserUnitTests