diff --git a/.gitignore b/.gitignore index 084b7af6..6f70a973 100644 --- a/.gitignore +++ b/.gitignore @@ -397,3 +397,4 @@ coverage/** lcov.info launchSettings.json tests/**/*.received.* +.nuget/ diff --git a/README.md b/README.md index 31363c5d..2586c013 100644 --- a/README.md +++ b/README.md @@ -58,16 +58,16 @@ nuget-license [options] | `-i`, `--input ` | Project or solution file to analyze. | | `-ji`, `--json-input ` | JSON file with an array of project/solution files to analyze. See [docs/input-json.md](docs/input-json.md). | | `-t`, `--include-transitive` | Include transitive dependencies. | -| `-a`, `--allowed-license-types ` | JSON file listing allowed license types. See [docs/allowed-licenses-json.md](docs/allowed-licenses-json.md). | -| `-ignore`, `--ignored-packages ` | JSON file listing package names to ignore (supports wildcards). See [docs/ignored-packages-json.md](docs/ignored-packages-json.md). | -| `-mapping`, `--licenseurl-to-license-mappings ` | JSON dictionary mapping license URLs to license types. See [docs/licenseurl-mappings-json.md](docs/licenseurl-mappings-json.md). | -| `-file-mapping`, `--licensefile-to-license-mappings ` | JSON dictionary mapping license files to license types. Paths are relative to the JSON file. See [docs/licensefile-mappings-json.md](docs/licensefile-mappings-json.md). | -| `-override`, `--override-package-information ` | JSON list to override package/license info. See [docs/override-package-json.md](docs/override-package-json.md). | -| `-d`, `--license-information-download-location ` | Download all license files to the specified folder. | -| `-o`, `--output ` | Output format: `Table`, `Markdown`, `Json` or `JsonPretty` (default: Table). See [docs/output-json.md](docs/output-json.md) for JSON format details. | -| `-err`, `--error-only` | Only show validation errors. | -| `-include-ignored`, `--include-ignored-packages` | Include ignored packages in output. | -| `-exclude-projects`, `--exclude-projects-matching ` | Exclude projects by name or pattern (supports wildcards or JSON file). See [docs/exclude-projects-json.md](docs/exclude-projects-json.md). | +| `-a`, `--allowed-license-types ` | Specifies allowed license types. You can provide either a JSON file listing allowed license types (see [docs/allowed-licenses-json.md](docs/allowed-licenses-json.md)), or a semicolon-separated list (e.g., `"MIT;Apache-2.0;BSD-3-Clause"`). | +| `-ignore`, `--ignored-packages ` | Specifies package names to ignore during validation (supports wildcards). You can provide either a JSON file (see [docs/ignored-packages-json.md](docs/ignored-packages-json.md)), or a semicolon-separated list (e.g., `"Package1;Package2"`). | +| `-mapping`, `--licenseurl-to-license-mappings ` | Specifies a JSON dictionary mapping license URLs to license types. See [docs/licenseurl-mappings-json.md](docs/licenseurl-mappings-json.md). | +| `-file-mapping`, `--licensefile-to-license-mappings ` | Specifies a JSON dictionary mapping license files to license types. Paths are relative to the JSON file. See [docs/licensefile-mappings-json.md](docs/licensefile-mappings-json.md). | +| `-override`, `--override-package-information ` | Specifies a JSON list to override package/license information. See [docs/override-package-json.md](docs/override-package-json.md). | +| `-d`, `--license-information-download-location ` | Specifies a folder where all license files will be downloaded. | +| `-o`, `--output ` | Specifies the output format: `Table`, `Markdown`, `Json` or `JsonPretty` (default: Table). See [docs/output-json.md](docs/output-json.md) for JSON format details. | +| `-err`, `--error-only` | When set, only validation errors are shown. | +| `-include-ignored`, `--include-ignored-packages` | When set, ignored packages are included in the output. | +| `-exclude-projects`, `--exclude-projects-matching ` | Specifies projects to exclude from analysis. You can provide either a JSON file (see [docs/exclude-projects-json.md](docs/exclude-projects-json.md)), or a semicolon-separated list (e.g., `"*Test*;Legacy*"`). Wildcards (`*`) are supported. | | `-isp`, `--include-shared-projects` | Include shared projects (`.shproj`). | | `-f`, `--target-framework ` | Analyze for a specific Target Framework Moniker. | | `-fo`, `--file-output ` | Write output to a file instead of console. | @@ -128,10 +128,28 @@ nuget-license -i MySolution.sln ### Use a custom allowed license list +Using a JSON file: ```ps nuget-license -i MyProject.csproj -a allowed-licenses.json ``` +Using inline semicolon-separated values: +```ps +nuget-license -i MyProject.csproj -a "MIT;Apache-2.0;BSD-3-Clause" +``` + +### Ignore specific packages + +Using a JSON file: +```ps +nuget-license -i MyProject.csproj -ignore ignored-packages.json +``` + +Using inline semicolon-separated values (supports wildcards): +```ps +nuget-license -i MyProject.csproj -ignore "InternalPackage1;InternalPackage2;Test*" +``` + ### Generate pretty JSON output ```ps diff --git a/docs/allowed-licenses-json.md b/docs/allowed-licenses-json.md index 519fe94c..733eb699 100644 --- a/docs/allowed-licenses-json.md +++ b/docs/allowed-licenses-json.md @@ -1,10 +1,14 @@ -# Allowed Licenses JSON File Format (`--allowed-license-types`) +# Allowed Licenses (`--allowed-license-types`) -The allowed licenses JSON file is used with the `-a` or `--allowed-license-types` option to specify which license types are permitted. +The `-a` or `--allowed-license-types` option is used to specify which license types are permitted. -## Format +## Input Format -The file must contain a JSON array of license identifiers (SPDX or custom): +You can provide the allowed licenses in two ways: + +### 1. JSON File + +Provide a path to a JSON file containing an array of license identifiers (SPDX or custom): ```json [ @@ -14,4 +18,33 @@ The file must contain a JSON array of license identifiers (SPDX or custom): ] ``` -Each entry should be a string representing a license type. +**Example usage:** +```bash +nuget-license -i MyProject.csproj -a allowed-licenses.json +``` + +### 2. Inline Semicolon-Separated List + +Provide a semicolon-separated list of license identifiers directly on the command line: + +**Example usage:** +```bash +nuget-license -i MyProject.csproj -a "MIT;Apache-2.0;BSD-3-Clause" +``` + +**Note:** When using inline format, make sure to quote the entire list to prevent shell interpretation of special characters. + +## Format Detection + +The tool automatically detects whether the input is a file path or an inline list: +- If a file exists at the specified path, it will be read as a JSON file +- Otherwise, the input will be parsed as a semicolon-separated inline list + +**Important:** If you have a file in your current directory with a name that matches your inline value (e.g., a file named "MIT"), the tool will read from the file instead of parsing it as an inline value. In such cases, use a different file name or provide a full/relative path to disambiguate. + +## License Identifiers + +Each entry should be a string representing a license type. License identifiers can be: +- SPDX license identifiers (e.g., `MIT`, `Apache-2.0`, `GPL-3.0`) +- Custom license names +- SPDX license expressions (e.g., `MIT OR Apache-2.0`, `GPL-2.0-or-later WITH Classpath-exception-2.0`) diff --git a/docs/exclude-projects-json.md b/docs/exclude-projects-json.md index 39199b2d..37889801 100644 --- a/docs/exclude-projects-json.md +++ b/docs/exclude-projects-json.md @@ -1,10 +1,14 @@ -# Exclude Projects JSON File Format (`--exclude-projects-matching`) +# Exclude Projects (`--exclude-projects-matching`) -The exclude projects JSON file is used with the `-exclude-projects` or `--exclude-projects-matching` option to specify projects to exclude from analysis. +The `-exclude-projects` or `--exclude-projects-matching` option is used to specify projects to exclude from analysis. Common use cases include excluding test projects, sample projects, or build tools from the analysis when working with a solution file. -## Format +## Input Format -The file must contain a JSON array of project names or patterns. Wildcards (`*`) are supported: +You can provide the excluded projects in two ways: + +### 1. JSON File + +Provide a path to a JSON file containing an array of project names or patterns. Wildcards (`*`) are supported: ```json [ @@ -14,4 +18,35 @@ The file must contain a JSON array of project names or patterns. Wildcards (`*`) ] ``` -Each entry should be a string representing a project name or pattern. +**Example usage:** +```bash +nuget-license -i MySolution.sln -exclude-projects exclude-projects.json +``` + +### 2. Inline Semicolon-Separated List + +Provide a semicolon-separated list of project names or patterns directly on the command line. Wildcards (`*`) are supported: + +**Example usage:** +```bash +nuget-license -i MySolution.sln -exclude-projects "*Test*;SampleProject;Legacy*" +``` + +**Note:** When using inline format, make sure to quote the entire list to prevent shell interpretation of wildcards. + +## Format Detection + +The tool automatically detects whether the input is a file path or an inline list: +- If a file exists at the specified path, it will be read as a JSON file +- Otherwise, the input will be parsed as a semicolon-separated inline list + +**Important:** If you have a file in your current directory with a name that matches your inline value, the tool will read from the file instead of parsing it as an inline value. In such cases, use a different file name or provide a full/relative path to disambiguate. + +## Project Names + +Each entry should be a string representing a project name or pattern: +- Exact match: `"ProjectName"` +- Prefix wildcard: `"ProjectName*"` +- Suffix wildcard: `"*ProjectName"` +- Contains: `"*PartialName*"` +- Multiple wildcards: `"*Test*.csproj"` diff --git a/docs/ignored-packages-json.md b/docs/ignored-packages-json.md index d170bc9c..19068caa 100644 --- a/docs/ignored-packages-json.md +++ b/docs/ignored-packages-json.md @@ -1,10 +1,16 @@ -# Ignored Packages JSON File Format (`--ignored-packages`) +# Ignored Packages (`--ignored-packages`) -The ignored packages JSON file is used with the `-ignore` or `--ignored-packages` option to specify NuGet packages to skip during validation. +The `-ignore` or `--ignored-packages` option is used to specify NuGet packages to skip during validation. -## Format +**Note:** Even though packages are ignored, their transitive dependencies are not ignored unless explicitly listed. -The file must contain a JSON array of package names. Wildcards (`*`) are supported: +## Input Format + +You can provide the ignored packages in two ways: + +### 1. JSON File + +Provide a path to a JSON file containing an array of package names. Wildcards (`*`) are supported: ```json [ @@ -14,4 +20,34 @@ The file must contain a JSON array of package names. Wildcards (`*`) are support ] ``` -Each entry should be a string representing a package name or pattern. +**Example usage:** +```bash +nuget-license -i MyProject.csproj -ignore ignored-packages.json +``` + +### 2. Inline Semicolon-Separated List + +Provide a semicolon-separated list of package names directly on the command line. Wildcards (`*`) are supported: + +**Example usage:** +```bash +nuget-license -i MyProject.csproj -ignore "MyCompany.*;TestPackage;LegacyLib*" +``` + +**Note:** When using inline format, make sure to quote the entire list to prevent shell interpretation of wildcards. + +## Format Detection + +The tool automatically detects whether the input is a file path or an inline list: +- If a file exists at the specified path, it will be read as a JSON file +- Otherwise, the input will be parsed as a semicolon-separated inline list + +**Important:** If you have a file in your current directory with a name that matches your inline value, the tool will read from the file instead of parsing it as an inline value. In such cases, use a different file name or provide a full/relative path to disambiguate. + +## Package Names + +Each entry should be a string representing a package name or pattern: +- Exact match: `"PackageName"` +- Prefix wildcard: `"PackageName*"` +- Suffix wildcard: `"*PackageName"` +- Contains: `"*PartialName*"` diff --git a/src/NuGetLicense/LicenseValidationHandler.cs b/src/NuGetLicense/LicenseValidationHandler.cs index 835cfca8..dcd7b83c 100644 --- a/src/NuGetLicense/LicenseValidationHandler.cs +++ b/src/NuGetLicense/LicenseValidationHandler.cs @@ -116,11 +116,9 @@ public async Task HandleAsync(CommandLineOptions options, CancellationToken private Stream GetOutputStream(string? destinationFile) { - if (destinationFile is null) - { - return _outputStream; - } - return _fileSystem.File.Open(_fileSystem.Path.GetFullPath(destinationFile), FileMode.Create, FileAccess.Write, FileShare.None); + return destinationFile is null + ? _outputStream + : _fileSystem.File.Open(_fileSystem.Path.GetFullPath(destinationFile), FileMode.Create, FileAccess.Write, FileShare.None); } private async Task WriteValidationExceptions(IReadOnlyCollection validationExceptions) @@ -150,14 +148,41 @@ private CustomPackageInformation[] GetOverridePackageInformation(string? overrid return JsonSerializer.Deserialize(_fileSystem.File.ReadAllText(overridePackageInformation), serializerOptions)!; } - private string[] GetAllowedLicenses(string? allowedLicenses) + private string[] ParseStringArrayOrFile(string? value) { - if (allowedLicenses == null) + if (value == null) { return Array.Empty(); } - return JsonSerializer.Deserialize(_fileSystem.File.ReadAllText(allowedLicenses))!; + // Check if the value is a path to an existing file + if (_fileSystem.File.Exists(value)) + { + try + { + string fileContent = _fileSystem.File.ReadAllText(value); + string[]? result = JsonSerializer.Deserialize(fileContent); + return result ?? throw new ArgumentException($"File '{value}' contains invalid JSON: expected an array of strings but got null."); + } + catch (JsonException ex) + { + throw new ArgumentException($"Failed to parse JSON file '{value}': {ex.Message}", ex); + } + } + + // Parse as semicolon-separated inline values + string[] parts = value.Split([';'], StringSplitOptions.RemoveEmptyEntries); + // Trim each part manually for .NET Framework compatibility + for (int i = 0; i < parts.Length; i++) + { + parts[i] = parts[i].Trim(); + } + return Array.FindAll(parts, part => part.Length > 0); + } + + private string[] GetAllowedLicenses(string? allowedLicenses) + { + return ParseStringArrayOrFile(allowedLicenses); } private IImmutableDictionary GetLicenseMappings(string? licenseMapping) @@ -174,27 +199,12 @@ private IImmutableDictionary GetLicenseMappings(string? licenseMapp private string[] GetIgnoredPackages(string? ignoredPackages) { - if (ignoredPackages == null) - { - return Array.Empty(); - } - - return JsonSerializer.Deserialize(_fileSystem.File.ReadAllText(ignoredPackages))!; + return ParseStringArrayOrFile(ignoredPackages); } private string[] GetExcludedProjects(string? excludedProjects) { - if (excludedProjects == null) - { - return Array.Empty(); - } - - if (_fileSystem.File.Exists(excludedProjects)) - { - return JsonSerializer.Deserialize(_fileSystem.File.ReadAllText(excludedProjects))!; - } - - return [excludedProjects]; + return ParseStringArrayOrFile(excludedProjects); } private string[] GetInputFiles(string? inputFile, string? inputJsonFile) @@ -204,12 +214,9 @@ private string[] GetInputFiles(string? inputFile, string? inputJsonFile) return [inputFile]; } - if (inputJsonFile != null) - { - return JsonSerializer.Deserialize(_fileSystem.File.ReadAllText(inputJsonFile))!; - } - - throw new ArgumentException("Please provide an input file using --input or --input-file-json"); + return inputJsonFile != null + ? JsonSerializer.Deserialize(_fileSystem.File.ReadAllText(inputJsonFile))! + : throw new ArgumentException("Please provide an input file using --input or --json-input"); } private static IReadOnlyCollection GetPackagesPerProject( diff --git a/src/NuGetLicense/Program.cs b/src/NuGetLicense/Program.cs index ef493567..794d9045 100644 --- a/src/NuGetLicense/Program.cs +++ b/src/NuGetLicense/Program.cs @@ -35,48 +35,48 @@ public static RootCommand CreateRootCommand() var allowedLicensesOption = new Option("-a", "--allowed-license-types") { - Description = "File in json format that contains an array of all allowed license types" + Description = "Specifies allowed license types. You can provide either a JSON file containing an array of license types, or a semicolon-separated list of license identifiers (e.g., \"MIT;Apache-2.0;BSD-3-Clause\")." }; var ignoredPackagesOption = new Option("-ignore", "--ignored-packages") { - Description = "File in json format that contains an array of nuget package names to ignore (e.g. useful for nuget packages built in-house). Note that even though the packages are ignored, their transitive dependencies are not. Wildcard characters (*) are supported to specify ranges of ignored packages." + Description = "Specifies package names to ignore during validation. You can provide either a JSON file containing an array of package names, or a semicolon-separated list (e.g., \"Package1;Package2\"). Wildcards (*) are supported. Note that even though packages are ignored, their transitive dependencies are not." }; var licenseMappingOption = new Option("-mapping", "--licenseurl-to-license-mappings") { - Description = "File in json format that contains a dictionary to map license urls to licenses." + Description = "Specifies a JSON file containing a dictionary to map license URLs to license types." }; var overridePackageInformationOption = new Option("-override", "--override-package-information") { - Description = "File in json format that contains a list of package and license information which should be used in favor of the online version. This option can be used to override the license type of packages that e.g. specify the license as file." + Description = "Specifies a JSON file containing package and license information to use instead of the online version. This option can be used to override the license type of packages that specify the license as a file." }; var downloadLicenseInformationOption = new Option("-d", "--license-information-download-location") { - Description = "When set, the application downloads all licenses given using a license URL to the specified folder.", + Description = "Specifies a folder where the application will download all licenses provided via license URLs." }; var outputTypeOption = new Option("-o", "--output") { - Description = "This parameter allows to choose between tabular and json output.", + Description = "Specifies the output format. Valid values are Table, Markdown, Json, or JsonPretty (default: Table).", DefaultValueFactory = _ => OutputType.Table }; var returnErrorsOnlyOption = new Option("-err", "--error-only") { - Description = "If this option is set and there are license validation errors, only the errors are returned as result. Otherwise all validation results are always returned." + Description = "When set, only validation errors are returned as result. Otherwise, all validation results are always returned." }; var includeIgnoredPackagesOption = new Option("-include-ignored", "--include-ignored-packages") { - Description = "If this option is set, the packages that are ignored from validation are still included in the output." + Description = "When set, packages that are ignored from validation are still included in the output." }; var excludedProjectsOption = new Option("-exclude-projects", "--exclude-projects-matching") { - Description = "This option allows to specify project name(s) to exclude from the analysis. This can be useful to exclude test projects from the analysis when supplying a solution file as input. Wildcard characters (*) are supported to specify ranges of ignored projects. The input can either be a file name containing a list of project names in json format or a plain string that is then used as a single entry." + Description = "Specifies project names to exclude from the analysis. You can provide either a JSON file containing an array of project names, or a semicolon-separated list (e.g., \"*Test*;Legacy*\"). Wildcards (*) are supported. This is useful to exclude test projects when supplying a solution file as input." }; var includeSharedProjectsOption = new Option("-isp", "--include-shared-projects") diff --git a/tests/NuGetLicense.Test/LicenseValidationHandlerTest.cs b/tests/NuGetLicense.Test/LicenseValidationHandlerTest.cs index cb80aa4f..be591c84 100644 --- a/tests/NuGetLicense.Test/LicenseValidationHandlerTest.cs +++ b/tests/NuGetLicense.Test/LicenseValidationHandlerTest.cs @@ -212,6 +212,226 @@ public async Task HandleAsync_WithExcludedProjectsAsString_CompletesSuccessfully Assert.That(result, Is.EqualTo(0)); } + [Test] + public async Task HandleAsync_WithAllowedLicensesAsInlineList_CompletesSuccessfully() + { + // Arrange + string inputFile = "/test/project.csproj"; + string allowedLicenses = "MIT;Apache-2.0;BSD-3-Clause"; + _fileSystem.AddFile(inputFile, new MockFileData("")); + + CommandLineOptions options = new CommandLineOptions + { + InputFile = inputFile, + AllowedLicenses = allowedLicenses + }; + + // Setup mocks + _solutionPersistance.GetProjectsFromSolutionAsync(Arg.Any()).Returns(Task.FromResult>(Array.Empty())); + + // Act + int result = await _handler.HandleAsync(options); + + // Assert + Assert.That(result, Is.EqualTo(0)); + } + + [Test] + public async Task HandleAsync_WithIgnoredPackagesAsInlineList_CompletesSuccessfully() + { + // Arrange + string inputFile = "/test/project.csproj"; + string ignoredPackages = "MyCompany.*;TestPackage;LegacyLib*"; + _fileSystem.AddFile(inputFile, new MockFileData("")); + + CommandLineOptions options = new CommandLineOptions + { + InputFile = inputFile, + IgnoredPackages = ignoredPackages + }; + + // Setup mocks + _solutionPersistance.GetProjectsFromSolutionAsync(Arg.Any()).Returns(Task.FromResult>(Array.Empty())); + + // Act + int result = await _handler.HandleAsync(options); + + // Assert + Assert.That(result, Is.EqualTo(0)); + } + + [Test] + public async Task HandleAsync_WithExcludedProjectsAsInlineList_CompletesSuccessfully() + { + // Arrange + string inputFile = "/test/project.csproj"; + string excludedProjects = "*Test*;SampleProject;Legacy*"; + _fileSystem.AddFile(inputFile, new MockFileData("")); + + CommandLineOptions options = new CommandLineOptions + { + InputFile = inputFile, + ExcludedProjects = excludedProjects + }; + + // Setup mocks + _solutionPersistance.GetProjectsFromSolutionAsync(Arg.Any()).Returns(Task.FromResult>(Array.Empty())); + + // Act + int result = await _handler.HandleAsync(options); + + // Assert + Assert.That(result, Is.EqualTo(0)); + } + + [Test] + public async Task HandleAsync_WithInvalidJsonInAllowedLicensesFile_ThrowsInvalidOperationException() + { + // Arrange + string inputFile = "/test/project.csproj"; + string allowedLicensesFile = "/test/allowed.json"; + _fileSystem.AddFile(inputFile, new MockFileData("")); + _fileSystem.AddFile(allowedLicensesFile, new MockFileData("invalid json content")); + + CommandLineOptions options = new CommandLineOptions + { + InputFile = inputFile, + AllowedLicenses = allowedLicensesFile + }; + + // Setup mocks + _solutionPersistance.GetProjectsFromSolutionAsync(Arg.Any()).Returns(Task.FromResult>(Array.Empty())); + + // Act & Assert + ArgumentException? ex = Assert.ThrowsAsync(async () => + await _handler.HandleAsync(options)); + Assert.That(ex!.Message, Does.Contain("Failed to parse JSON file")); + Assert.That(ex.Message, Does.Contain(allowedLicensesFile)); + } + + [Test] + public async Task HandleAsync_WithNullJsonInAllowedLicensesFile_ThrowsInvalidOperationException() + { + // Arrange + string inputFile = "/test/project.csproj"; + string allowedLicensesFile = "/test/allowed.json"; + _fileSystem.AddFile(inputFile, new MockFileData("")); + _fileSystem.AddFile(allowedLicensesFile, new MockFileData("null")); + + CommandLineOptions options = new CommandLineOptions + { + InputFile = inputFile, + AllowedLicenses = allowedLicensesFile + }; + + // Setup mocks + _solutionPersistance.GetProjectsFromSolutionAsync(Arg.Any()).Returns(Task.FromResult>(Array.Empty())); + + // Act & Assert + ArgumentException? ex = Assert.ThrowsAsync(async () => + await _handler.HandleAsync(options)); + Assert.That(ex!.Message, Does.Contain("expected an array of strings but got null")); + Assert.That(ex.Message, Does.Contain(allowedLicensesFile)); + } + + [Test] + public async Task HandleAsync_WithInvalidJsonInIgnoredPackagesFile_ThrowsInvalidOperationException() + { + // Arrange + string inputFile = "/test/project.csproj"; + string ignoredPackagesFile = "/test/ignored.json"; + _fileSystem.AddFile(inputFile, new MockFileData("")); + _fileSystem.AddFile(ignoredPackagesFile, new MockFileData("{invalid}")); + + CommandLineOptions options = new CommandLineOptions + { + InputFile = inputFile, + IgnoredPackages = ignoredPackagesFile + }; + + // Setup mocks + _solutionPersistance.GetProjectsFromSolutionAsync(Arg.Any()).Returns(Task.FromResult>(Array.Empty())); + + // Act & Assert + ArgumentException? ex = Assert.ThrowsAsync(async () => + await _handler.HandleAsync(options)); + Assert.That(ex!.Message, Does.Contain("Failed to parse JSON file")); + Assert.That(ex.Message, Does.Contain(ignoredPackagesFile)); + } + + [Test] + public async Task HandleAsync_WithInvalidJsonInExcludedProjectsFile_ThrowsInvalidOperationException() + { + // Arrange + string inputFile = "/test/project.csproj"; + string excludedProjectsFile = "/test/excluded.json"; + _fileSystem.AddFile(inputFile, new MockFileData("")); + _fileSystem.AddFile(excludedProjectsFile, new MockFileData("not valid json")); + + CommandLineOptions options = new CommandLineOptions + { + InputFile = inputFile, + ExcludedProjects = excludedProjectsFile + }; + + // Setup mocks + _solutionPersistance.GetProjectsFromSolutionAsync(Arg.Any()).Returns(Task.FromResult>(Array.Empty())); + + // Act & Assert + ArgumentException? ex = Assert.ThrowsAsync(async () => + await _handler.HandleAsync(options)); + Assert.That(ex!.Message, Does.Contain("Failed to parse JSON file")); + Assert.That(ex.Message, Does.Contain(excludedProjectsFile)); + } + + [Test] + public async Task HandleAsync_WithEmptyInlineList_CompletesSuccessfully() + { + // Arrange + string inputFile = "/test/project.csproj"; + string emptyList = ""; + _fileSystem.AddFile(inputFile, new MockFileData("")); + + CommandLineOptions options = new CommandLineOptions + { + InputFile = inputFile, + AllowedLicenses = emptyList + }; + + // Setup mocks + _solutionPersistance.GetProjectsFromSolutionAsync(Arg.Any()).Returns(Task.FromResult>(Array.Empty())); + + // Act + int result = await _handler.HandleAsync(options); + + // Assert - empty string should result in empty array, which should complete successfully + Assert.That(result, Is.EqualTo(0)); + } + + [Test] + public async Task HandleAsync_WithWhitespaceInInlineList_TrimsCorrectly() + { + // Arrange + string inputFile = "/test/project.csproj"; + string listWithWhitespace = " MIT ; Apache-2.0 ; BSD-3-Clause "; + _fileSystem.AddFile(inputFile, new MockFileData("")); + + CommandLineOptions options = new CommandLineOptions + { + InputFile = inputFile, + AllowedLicenses = listWithWhitespace + }; + + // Setup mocks + _solutionPersistance.GetProjectsFromSolutionAsync(Arg.Any()).Returns(Task.FromResult>(Array.Empty())); + + // Act + int result = await _handler.HandleAsync(options); + + // Assert - whitespace should be trimmed and parsing should succeed + Assert.That(result, Is.EqualTo(0)); + } + [Test] public async Task HandleAsync_WithDownloadLicenseInformation_CreatesDirectory() {