Skip to content

Commit a2d7f66

Browse files
committed
Centralize CLI argument parsing
1 parent acada79 commit a2d7f66

13 files changed

Lines changed: 285 additions & 179 deletions

CHANGELOG.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,10 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
99

1010
### [Unreleased]
1111

12+
#### Changed
13+
14+
- **CLI parsing now returns one structured result and rejects surplus positional arguments** — `CliParser` now separates `oldFolder`, `newFolder`, and optional `reportLabel` while consuming options in one pass. Existing two- and three-positional forms, option placement, automatic labels, and `--creator` behavior are preserved; a fourth positional argument now prints usage and exits with code `2`. `CliOptions` uses named properties with defaults instead of a 35-field positional constructor. Affected: `Runner/CliParser.cs`, `Runner/CliOptions.cs`, `ProgramRunner.cs`, `Runner/ProgramRunner.Wizard.cs`, `Runner/RunPreflightValidator.cs`, `USER_GUIDE.md`. Tests: `CliOptionsTests`, `ProgramRunnerTests`, `CliOverrideApplierTests`, `SpinnerThemesTests`.
15+
1216
### [1.21.0] - 2026-07-22
1317

1418
#### Added
@@ -1666,6 +1670,10 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
16661670

16671671
### [Unreleased]
16681672

1673+
#### 変更
1674+
1675+
- **CLI 解析を単一の構造化結果へ集約し、余分な位置引数を拒否** — `CliParser` はオプションを 1 回の走査で消費しながら、`oldFolder`、`newFolder`、任意の `reportLabel` を分離するようになりました。既存の 2/3 位置引数形式、オプション位置、自動ラベル、`--creator` の動作は維持し、4 個目の位置引数は使い方を表示して終了コード `2` で拒否します。`CliOptions` は 35 フィールドの位置指定コンストラクタではなく、既定値付きの名前付きプロパティを使用します。対象: `Runner/CliParser.cs`, `Runner/CliOptions.cs`, `ProgramRunner.cs`, `Runner/ProgramRunner.Wizard.cs`, `Runner/RunPreflightValidator.cs`, `USER_GUIDE.md`。テスト: `CliOptionsTests`, `ProgramRunnerTests`, `CliOverrideApplierTests`, `SpinnerThemesTests`。
1676+
16691677
### [1.21.0] - 2026-07-22
16701678

16711679
#### 追加

FolderDiffIL4DotNet.Tests/CliOptionsTests.Combined.cs

Lines changed: 102 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -37,29 +37,125 @@ public void ParseCliOptions_AllFlagsCombined_ParsedCorrectly()
3737
Assert.True(opts.Bell);
3838
Assert.True(opts.Wizard);
3939
Assert.Equal("json", opts.LogFormatOverride);
40+
Assert.Equal("/old", opts.OldFolder);
41+
Assert.Equal("/new", opts.NewFolder);
42+
Assert.Equal("lbl", opts.ReportLabel);
4043
Assert.Null(opts.ParseError);
4144
}
4245

4346
[Fact]
44-
public void ExtractPositionalArguments_WithOptionAsThirdToken_ReturnsOnlyFolders()
47+
public void ParseCliOptions_ThreePositionalsWithCreatorNoCacheAndCoffee_ParsesCorrectly()
4548
{
46-
var positionalArgs = CliParser.ExtractPositionalArguments(new[]
49+
var opts = CliParser.Parse(new[]
50+
{
51+
"oldfolder", "newfolder", "reportlabel", "--creator", "--no-il-cache", "--coffee",
52+
});
53+
54+
Assert.Equal("oldfolder", opts.OldFolder);
55+
Assert.Equal("newfolder", opts.NewFolder);
56+
Assert.Equal("reportlabel", opts.ReportLabel);
57+
Assert.True(opts.Creator);
58+
Assert.True(opts.NoIlCache);
59+
Assert.True(opts.Coffee);
60+
Assert.Null(opts.ParseError);
61+
}
62+
63+
[Fact]
64+
public void ParseCliOptions_WithOptionAsThirdToken_SeparatesFolders()
65+
{
66+
var opts = CliParser.Parse(new[]
4767
{
4868
"/old", "/new", "--beer", "--dry-run",
4969
});
5070

51-
Assert.Equal(new[] { "/old", "/new" }, positionalArgs);
71+
Assert.Equal("/old", opts.OldFolder);
72+
Assert.Equal("/new", opts.NewFolder);
73+
Assert.Null(opts.ReportLabel);
74+
Assert.True(opts.Beer);
75+
Assert.True(opts.DryRun);
76+
Assert.Null(opts.ParseError);
5277
}
5378

5479
[Fact]
55-
public void ExtractPositionalArguments_WithExplicitLabelAndOptions_PreservesReportLabel()
80+
public void ParseCliOptions_WithExplicitLabelAndOptions_SeparatesReportLabel()
5681
{
57-
var positionalArgs = CliParser.ExtractPositionalArguments(new[]
82+
var opts = CliParser.Parse(new[]
5883
{
5984
"/old", "/new", "release_20260411", "--config", "/tmp/config.json", "--beer",
6085
});
6186

62-
Assert.Equal(new[] { "/old", "/new", "release_20260411" }, positionalArgs);
87+
Assert.Equal("/old", opts.OldFolder);
88+
Assert.Equal("/new", opts.NewFolder);
89+
Assert.Equal("release_20260411", opts.ReportLabel);
90+
Assert.Equal("/tmp/config.json", opts.ConfigPath);
91+
Assert.True(opts.Beer);
92+
Assert.Null(opts.ParseError);
93+
}
94+
95+
[Theory]
96+
[InlineData("--creator", "/old", "/new")]
97+
[InlineData("/old", "--creator", "/new")]
98+
[InlineData("/old", "/new", "--creator")]
99+
public void ParseCliOptions_CreatorWithTwoPositionals_PreservesOptionPlacement(
100+
string first,
101+
string second,
102+
string third)
103+
{
104+
var opts = CliParser.Parse(new[] { first, second, third });
105+
106+
Assert.Equal("/old", opts.OldFolder);
107+
Assert.Equal("/new", opts.NewFolder);
108+
Assert.Null(opts.ReportLabel);
109+
Assert.True(opts.Creator);
110+
Assert.Null(opts.ParseError);
111+
}
112+
113+
[Theory]
114+
[InlineData(0)]
115+
[InlineData(1)]
116+
[InlineData(2)]
117+
public void ParseCliOptions_ValueOption_PreservesOptionPlacement(int placement)
118+
{
119+
string[] args = placement switch
120+
{
121+
0 => new[] { "--config", "/tmp/config.json", "/old", "/new" },
122+
1 => new[] { "/old", "--config", "/tmp/config.json", "/new" },
123+
_ => new[] { "/old", "/new", "--config", "/tmp/config.json" },
124+
};
125+
126+
var opts = CliParser.Parse(args);
127+
128+
Assert.Equal("/old", opts.OldFolder);
129+
Assert.Equal("/new", opts.NewFolder);
130+
Assert.Null(opts.ReportLabel);
131+
Assert.Equal("/tmp/config.json", opts.ConfigPath);
132+
Assert.Null(opts.ParseError);
133+
}
134+
135+
[Fact]
136+
public void ParseCliOptions_ExplicitLabelThenCreator_PreservesLabelAndCreatorProfile()
137+
{
138+
var opts = CliParser.Parse(new[] { "/old", "/new", "label", "--creator" });
139+
140+
Assert.Equal("/old", opts.OldFolder);
141+
Assert.Equal("/new", opts.NewFolder);
142+
Assert.Equal("label", opts.ReportLabel);
143+
Assert.True(opts.Creator);
144+
Assert.Null(opts.CreatorIlIgnoreProfile);
145+
Assert.Null(opts.ParseError);
146+
}
147+
148+
[Fact]
149+
public void ParseCliOptions_FourthPositionalArgument_SetsUsageParseError()
150+
{
151+
var opts = CliParser.Parse(new[] { "/old", "/new", "label", "surplus" });
152+
153+
Assert.Equal("/old", opts.OldFolder);
154+
Assert.Equal("/new", opts.NewFolder);
155+
Assert.Equal("label", opts.ReportLabel);
156+
Assert.NotNull(opts.ParseError);
157+
Assert.Contains("surplus", opts.ParseError, System.StringComparison.Ordinal);
158+
Assert.Contains("Usage:", opts.ParseError, System.StringComparison.Ordinal);
63159
}
64160

65161
// -----------------------------------------------------------------------

FolderDiffIL4DotNet.Tests/CliOptionsTests.cs

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,9 @@ public void ParseCliOptions_NullArgs_ReturnsAllDefaults()
1414
{
1515
var opts = CliParser.Parse(null);
1616

17+
Assert.Null(opts.OldFolder);
18+
Assert.Null(opts.NewFolder);
19+
Assert.Null(opts.ReportLabel);
1720
Assert.False(opts.ShowHelp);
1821
Assert.False(opts.ShowVersion);
1922
Assert.False(opts.ShowBanner);
@@ -63,6 +66,9 @@ public void ParseCliOptions_PositionalArgsOnly_ReturnsAllDefaultFlags()
6366
{
6467
var opts = CliParser.Parse(new[] { "/old", "/new", "label" });
6568

69+
Assert.Equal("/old", opts.OldFolder);
70+
Assert.Equal("/new", opts.NewFolder);
71+
Assert.Equal("label", opts.ReportLabel);
6672
Assert.False(opts.ShowHelp);
6773
Assert.False(opts.ShowVersion);
6874
Assert.False(opts.ShowBanner);

FolderDiffIL4DotNet.Tests/ProgramRunnerTests.HelpVersion.cs

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1429,5 +1429,21 @@ await WithConfigFileAsync("{}", async () =>
14291429
TryDeleteDirectory(tempRoot);
14301430
}
14311431
}
1432+
1433+
[Fact]
1434+
public async Task RunAsync_FourthPositionalArgument_ReturnsInvalidArgumentsAndPrintsUsage()
1435+
{
1436+
var logger = new TestLogger();
1437+
var runner = new ProgramRunner(logger, new ConfigService());
1438+
1439+
var exitCode = await runner.RunAsync(new[] { "old", "new", "label", "surplus", "--no-pause" });
1440+
1441+
Assert.Equal(2, exitCode);
1442+
Assert.Contains(
1443+
logger.Entries,
1444+
entry => entry.ShouldOutputMessageToConsole
1445+
&& entry.Message.Contains("surplus", StringComparison.Ordinal)
1446+
&& entry.Message.Contains("Usage:", StringComparison.Ordinal));
1447+
}
14321448
}
14331449
}

FolderDiffIL4DotNet.Tests/ProgramRunnerTests.Preflight.cs

Lines changed: 9 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -145,45 +145,45 @@ public void FormatElapsedTime_VariousInputs_ReturnsExpectedString(
145145
// -----------------------------------------------------------------------
146146

147147
[Fact]
148-
public void ValidateRequiredArguments_NullArgs_ThrowsArgumentException()
148+
public void ValidateRequiredArguments_MissingFolders_ThrowsArgumentException()
149149
{
150-
Assert.Throws<ArgumentException>(() => RunPreflightValidator.ValidateRequiredArguments(null));
150+
Assert.Throws<ArgumentException>(() => RunPreflightValidator.ValidateRequiredArguments(null, null, null));
151151
}
152152

153153
[Fact]
154-
public void ValidateRequiredArguments_TooFewArgs_ThrowsArgumentException()
154+
public void ValidateRequiredArguments_MissingNewFolder_ThrowsArgumentException()
155155
{
156-
Assert.Throws<ArgumentException>(() => RunPreflightValidator.ValidateRequiredArguments(["a"]));
156+
Assert.Throws<ArgumentException>(() => RunPreflightValidator.ValidateRequiredArguments("a", null, null));
157157
}
158158

159159
[Fact]
160160
public void ValidateRequiredArguments_EmptyFirstArg_ThrowsArgumentException()
161161
{
162-
Assert.Throws<ArgumentException>(() => RunPreflightValidator.ValidateRequiredArguments(["", "b", "c"]));
162+
Assert.Throws<ArgumentException>(() => RunPreflightValidator.ValidateRequiredArguments("", "b", "c"));
163163
}
164164

165165
[Fact]
166166
public void ValidateRequiredArguments_WhitespaceSecondArg_ThrowsArgumentException()
167167
{
168-
Assert.Throws<ArgumentException>(() => RunPreflightValidator.ValidateRequiredArguments(["a", " ", "c"]));
168+
Assert.Throws<ArgumentException>(() => RunPreflightValidator.ValidateRequiredArguments("a", " ", "c"));
169169
}
170170

171171
[Fact]
172172
public void ValidateRequiredArguments_WhitespaceThirdArg_ThrowsArgumentException()
173173
{
174-
Assert.Throws<ArgumentException>(() => RunPreflightValidator.ValidateRequiredArguments(["a", "b", ""]));
174+
Assert.Throws<ArgumentException>(() => RunPreflightValidator.ValidateRequiredArguments("a", "b", ""));
175175
}
176176

177177
[Fact]
178178
public void ValidateRequiredArguments_ValidArgs_DoesNotThrow()
179179
{
180-
RunPreflightValidator.ValidateRequiredArguments(["old", "new", "label"]);
180+
RunPreflightValidator.ValidateRequiredArguments("old", "new", "label");
181181
}
182182

183183
[Fact]
184184
public void ValidateRequiredArguments_TwoArgs_DoesNotThrow()
185185
{
186-
RunPreflightValidator.ValidateRequiredArguments(["old", "new"]);
186+
RunPreflightValidator.ValidateRequiredArguments("old", "new", null);
187187
}
188188

189189
[Fact]

FolderDiffIL4DotNet.Tests/Runner/CliOverrideApplierTests.cs

Lines changed: 1 addition & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -11,16 +11,7 @@ namespace FolderDiffIL4DotNet.Tests.Runner
1111
[Trait("Category", "Unit")]
1212
public sealed class CliOverrideApplierTests
1313
{
14-
private static CliOptions DefaultOpts() =>
15-
new(ShowHelp: false, ShowVersion: false, ShowBanner: false, NoBanner: false, Doctor: false, NoPause: false,
16-
ConfigPath: null, ThreadsOverride: null, NoIlCache: false, ClearCache: false,
17-
SkipIL: false, NoTimestampWarnings: false, Creator: false, CreatorIlIgnoreProfile: null, PrintConfig: false, ValidateConfig: false,
18-
DryRun: false, Coffee: false, Beer: false, Matcha: false, Whisky: false,
19-
Wine: false, Ramen: false, Sushi: false, Bell: false, Wizard: false,
20-
ShowCredits: false, RandomSpinner: false, MultipleSpinnersDetected: false,
21-
LogFormatOverride: null, OutputDirectory: null,
22-
OpenReports: false, OpenConfig: false, OpenLogs: false,
23-
ParseError: null);
14+
private static CliOptions DefaultOpts() => new();
2415

2516
[Fact]
2617
public void Apply_ThreadsOverride_SetsMaxParallelism()

FolderDiffIL4DotNet.Tests/Runner/SpinnerThemesTests.cs

Lines changed: 1 addition & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -12,16 +12,7 @@ namespace FolderDiffIL4DotNet.Tests.Runner
1212
[Trait("Category", "Unit")]
1313
public sealed class SpinnerThemesTests
1414
{
15-
private static CliOptions DefaultOpts() =>
16-
new(ShowHelp: false, ShowVersion: false, ShowBanner: false, NoBanner: false, Doctor: false, NoPause: false,
17-
ConfigPath: null, ThreadsOverride: null, NoIlCache: false, ClearCache: false,
18-
SkipIL: false, NoTimestampWarnings: false, Creator: false, CreatorIlIgnoreProfile: null, PrintConfig: false, ValidateConfig: false,
19-
DryRun: false, Coffee: false, Beer: false, Matcha: false, Whisky: false,
20-
Wine: false, Ramen: false, Sushi: false, Bell: false, Wizard: false,
21-
ShowCredits: false, RandomSpinner: false, MultipleSpinnersDetected: false,
22-
LogFormatOverride: null, OutputDirectory: null,
23-
OpenReports: false, OpenConfig: false, OpenLogs: false,
24-
ParseError: null);
15+
private static CliOptions DefaultOpts() => new();
2516

2617
[Fact]
2718
public void MultipleSpinnersMessage_HasExpectedContent()

ProgramRunner.cs

Lines changed: 10 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -94,7 +94,7 @@ public async Task<int> RunAsync(string[] args)
9494
return await RunWizardAsync(opts);
9595
}
9696

97-
var result = await RunWithResultAsync(args, opts);
97+
var result = await RunWithResultAsync(opts);
9898
OutputCompletionWarnings(result.HasSha256MismatchWarnings, result.HasTimestampRegressionWarnings, result.HasILFilterWarnings);
9999

100100
// Ring terminal bell on completion if requested / 要求された場合、完了時にターミナルベルを鳴らす
@@ -125,7 +125,7 @@ public async Task<int> RunAsync(string[] args)
125125
/// Converts the entire run into a typed result and maps it to the public API exit code at the application boundary.
126126
/// 実行全体を型付き結果へ変換し、公開 API である終了コードへ写像する境界処理です。
127127
/// </summary>
128-
private async Task<ProgramRunResult> RunWithResultAsync(string[] args, CliOptions opts)
128+
private async Task<ProgramRunResult> RunWithResultAsync(CliOptions opts)
129129
{
130130
#pragma warning disable CA1031 // Top-level application boundary classifies unexpected failures after logging.
131131
try
@@ -135,7 +135,7 @@ private async Task<ProgramRunResult> RunWithResultAsync(string[] args, CliOption
135135

136136
// Railway-oriented pipeline: each step short-circuits on failure.
137137
// Railway 指向パイプライン: 各ステップは失敗時にショートサーキットします。
138-
var argsResult = TryValidateAndBuildRunArguments(args, opts);
138+
var argsResult = TryValidateAndBuildRunArguments(opts);
139139

140140
// Dry-run does not need the Reports directory / ドライランでは Reports ディレクトリ不要
141141
if (!opts.DryRun)
@@ -299,7 +299,7 @@ private void OutputCompletionWarnings(bool hasSha256MismatchWarnings, bool hasTi
299299
/// Returns the CLI argument validation phase as a typed result.
300300
/// CLI 引数検証フェーズを型付き結果として返します。
301301
/// </summary>
302-
private StepResult<RunArguments> TryValidateAndBuildRunArguments(string[] args, CliOptions opts)
302+
private StepResult<RunArguments> TryValidateAndBuildRunArguments(CliOptions opts)
303303
{
304304
try
305305
{
@@ -309,17 +309,16 @@ private StepResult<RunArguments> TryValidateAndBuildRunArguments(string[] args,
309309
throw new ArgumentException(opts.ParseError);
310310
}
311311

312-
var positionalArgs = CliParser.ExtractPositionalArguments(args);
313-
RunPreflightValidator.ValidateRequiredArguments(positionalArgs);
312+
RunPreflightValidator.ValidateRequiredArguments(opts.OldFolder, opts.NewFolder, opts.ReportLabel);
314313

315-
var oldFolderAbsolutePath = Path.GetFullPath(positionalArgs[0].Trim('"'));
316-
var newFolderAbsolutePath = Path.GetFullPath(positionalArgs[1].Trim('"'));
314+
var oldFolderAbsolutePath = Path.GetFullPath(opts.OldFolder!.Trim('"'));
315+
var newFolderAbsolutePath = Path.GetFullPath(opts.NewFolder!.Trim('"'));
317316
string reportsRootDirAbsolutePath = RunPreflightValidator.GetReportsRootDirectoryAbsolutePath(opts.OutputDirectory, _logger);
318-
var reportLabel = positionalArgs.Length >= 3
319-
? positionalArgs[2]
317+
var reportLabel = opts.ReportLabel != null
318+
? opts.ReportLabel
320319
: RunPreflightValidator.GenerateAutomaticReportLabel(reportsRootDirAbsolutePath);
321320

322-
if (positionalArgs.Length < 3)
321+
if (opts.ReportLabel == null)
323322
{
324323
_logger.LogMessage(
325324
AppLogLevel.Info,

0 commit comments

Comments
 (0)