diff --git a/src/NuGetLicense/CommandLineOptionsParser.cs b/src/NuGetLicense/CommandLineOptionsParser.cs index 77445e36..78db9b59 100644 --- a/src/NuGetLicense/CommandLineOptionsParser.cs +++ b/src/NuGetLicense/CommandLineOptionsParser.cs @@ -130,14 +130,17 @@ public IFileDownloader GetFileDownloader(string? downloadLicenseInformation) return new FileDownloader(_httpClient, downloadLicenseInformation); } - public IOutputFormatter GetOutputFormatter(OutputType outputType, bool returnErrorsOnly, bool includeIgnoredPackages) + public IOutputFormatter GetOutputFormatter(OutputType outputType, bool returnErrorsOnly, bool includeIgnoredPackages, string? includedColumns) { + string[] columns = ParseIncludedColumns(includedColumns); + return outputType switch { OutputType.Json => new Output.Json.JsonOutputFormatter(false, returnErrorsOnly, !includeIgnoredPackages), OutputType.JsonPretty => new Output.Json.JsonOutputFormatter(true, returnErrorsOnly, !includeIgnoredPackages), - OutputType.Table => new Output.Table.TableOutputFormatter(returnErrorsOnly, !includeIgnoredPackages), - OutputType.Markdown => new Output.Table.TableOutputFormatter(returnErrorsOnly, !includeIgnoredPackages, printMarkdown: true), + OutputType.Table => new Output.Table.TableOutputFormatter(returnErrorsOnly, !includeIgnoredPackages, columns), + OutputType.Markdown => new Output.Table.TableOutputFormatter(returnErrorsOnly, !includeIgnoredPackages, columns, printMarkdown: true), + OutputType.Csv => new Output.Csv.CsvOutputFormatter(returnErrorsOnly, !includeIgnoredPackages, columns), _ => throw new ArgumentOutOfRangeException($"{outputType} not supported") }; } @@ -173,5 +176,22 @@ private string[] ParseStringArrayOrFile(string? value) } return Array.FindAll(parts, part => part.Length > 0); } + + private string[] ParseIncludedColumns(string? value) + { + if (value is null) + { + return Array.Empty(); + } + + // 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); + } } } diff --git a/src/NuGetLicense/ICommandLineOptions.cs b/src/NuGetLicense/ICommandLineOptions.cs index a7fa5d37..1c799191 100644 --- a/src/NuGetLicense/ICommandLineOptions.cs +++ b/src/NuGetLicense/ICommandLineOptions.cs @@ -24,5 +24,6 @@ public interface ICommandLineOptions public string? DestinationFile { get; } public string? LicenseFileMappings { get; } public bool ExcludePublishFalse { get; } + public string? IncludedColumns { get; } } } diff --git a/src/NuGetLicense/ICommandLineOptionsParser.cs b/src/NuGetLicense/ICommandLineOptionsParser.cs index 64301d84..2d055d0f 100644 --- a/src/NuGetLicense/ICommandLineOptionsParser.cs +++ b/src/NuGetLicense/ICommandLineOptionsParser.cs @@ -58,6 +58,6 @@ public interface ICommandLineOptionsParser /// /// Gets the output formatter from the command line options. /// - IOutputFormatter GetOutputFormatter(OutputType outputType, bool returnErrorsOnly, bool includeIgnoredPackages); + IOutputFormatter GetOutputFormatter(OutputType outputType, bool returnErrorsOnly, bool includeIgnoredPackages, string? includedColumns); } } diff --git a/src/NuGetLicense/LicenseValidationOrchestrator.cs b/src/NuGetLicense/LicenseValidationOrchestrator.cs index 171f3423..c07fed06 100644 --- a/src/NuGetLicense/LicenseValidationOrchestrator.cs +++ b/src/NuGetLicense/LicenseValidationOrchestrator.cs @@ -60,7 +60,7 @@ public async Task ValidateAsync(ICommandLineOptions options, CancellationTo string[] allowedLicensesArray = _optionsParser.GetAllowedLicenses(options.AllowedLicenses); CustomPackageInformation[] overridePackageInformationArray = _optionsParser.GetOverridePackageInformation(options.OverridePackageInformation); IFileDownloader licenseDownloader = _optionsParser.GetFileDownloader(options.DownloadLicenseInformation); - IOutputFormatter output = _optionsParser.GetOutputFormatter(options.OutputType, options.ReturnErrorsOnly, options.IncludeIgnoredPackages); + IOutputFormatter output = _optionsParser.GetOutputFormatter(options.OutputType, options.ReturnErrorsOnly, options.IncludeIgnoredPackages, options.IncludedColumns); var projectCollector = new ProjectsCollector(_solutionPersistance, _fileSystem); var projectReader = new ReferencedPackageReader( diff --git a/src/NuGetLicense/Output/Csv/CsvOutputFormatter.cs b/src/NuGetLicense/Output/Csv/CsvOutputFormatter.cs new file mode 100644 index 00000000..112273a8 --- /dev/null +++ b/src/NuGetLicense/Output/Csv/CsvOutputFormatter.cs @@ -0,0 +1,101 @@ +// Licensed to the projects contributors. +// The license conditions are provided in the LICENSE file located in the project root + +using System.Globalization; +using NuGetLicense.LicenseValidator; + +namespace NuGetLicense.Output.Csv +{ + /// + /// Outputs license validation results in CSV format. + /// + public class CsvOutputFormatter : IOutputFormatter + { + private readonly bool _printErrorsOnly; + private readonly bool _skipIgnoredPackages; + private readonly string[] _includedColumns; + + public CsvOutputFormatter(bool printErrorsOnly, bool skipIgnoredPackages, string[]? includedColumns = null) + { + _printErrorsOnly = printErrorsOnly; + _skipIgnoredPackages = skipIgnoredPackages; + _includedColumns = includedColumns ?? Array.Empty(); + } + + public async Task Write(Stream stream, IList results) + { + if (_printErrorsOnly) + { + results = results.Where(r => r.ValidationErrors.Any()).ToList(); + } + else if (_skipIgnoredPackages) + { + results = results.Where(r => r.LicenseInformationOrigin != LicenseInformationOrigin.Ignored).ToList(); + } + + using var writer = new StreamWriter(stream, leaveOpen: true); + + // Define all available columns + var allColumns = new Dictionary>(StringComparer.OrdinalIgnoreCase) + { + ["Package"] = r => r.PackageId, + ["Version"] = r => r.PackageVersion.ToString(), + ["LicenseInformationOrigin"] = r => r.LicenseInformationOrigin.ToString(), + ["LicenseExpression"] = r => r.License, + ["LicenseUrl"] = r => r.LicenseUrl, + ["Copyright"] = r => r.Copyright, + ["Authors"] = r => r.Authors, + ["Description"] = r => r.Description, + ["Summary"] = r => r.Summary, + ["Error"] = r => r.ValidationErrors.Any() ? string.Join("; ", r.ValidationErrors.Select(e => e.Error)) : null, + ["ErrorContext"] = r => r.ValidationErrors.Any() ? string.Join("; ", r.ValidationErrors.Select(e => e.Context)) : null + }; + + // Determine which columns to include + var columnsToInclude = _includedColumns.Length > 0 + ? allColumns.Where(c => _includedColumns.Contains(c.Key, StringComparer.OrdinalIgnoreCase)).ToList() + : allColumns.ToList(); + + // If no valid columns specified, use all columns + if (columnsToInclude.Count == 0) + { + columnsToInclude = allColumns.ToList(); + } + + // Write header + var header = string.Join(",", columnsToInclude.Select(c => EscapeCsvField(c.Key))); + await writer.WriteLineAsync(header); + + // Write rows + foreach (var result in results) + { + var values = columnsToInclude.Select(c => EscapeCsvField(c.Value(result))); + var line = string.Join(",", values); + await writer.WriteLineAsync(line); + } + + await writer.FlushAsync(); + } + + /// + /// Escapes a field for CSV output. + /// Handles fields containing commas, quotes, or newlines. + /// + private static string EscapeCsvField(string? field) + { + if (string.IsNullOrEmpty(field)) + { + return ""; + } + + // If the field contains comma, quote, or newline, wrap it in quotes + if (field.Contains(',') || field.Contains('"') || field.Contains('\n') || field.Contains('\r')) + { + // Replace double quotes with two double quotes + return "\"" + field.Replace("\"", "\"\"") + "\""; + } + + return field; + } + } +} diff --git a/src/NuGetLicense/Output/Table/TableOutputFormatter.cs b/src/NuGetLicense/Output/Table/TableOutputFormatter.cs index a0aa6fe2..f5999bb3 100644 --- a/src/NuGetLicense/Output/Table/TableOutputFormatter.cs +++ b/src/NuGetLicense/Output/Table/TableOutputFormatter.cs @@ -11,12 +11,14 @@ public class TableOutputFormatter : IOutputFormatter private readonly bool _printErrorsOnly; private readonly bool _skipIgnoredPackages; private readonly bool _printMarkdown; + private readonly string[] _includedColumns; - public TableOutputFormatter(bool printErrorsOnly, bool skipIgnoredPackages, bool printMarkdown = false) + public TableOutputFormatter(bool printErrorsOnly, bool skipIgnoredPackages, string[]? includedColumns = null, bool printMarkdown = false) { _printErrorsOnly = printErrorsOnly; _skipIgnoredPackages = skipIgnoredPackages; _printMarkdown = printMarkdown; + _includedColumns = includedColumns ?? Array.Empty(); } public async Task Write(Stream stream, IList results) @@ -53,7 +55,32 @@ public async Task Write(Stream stream, IList results) results = results.Where(r => r.LicenseInformationOrigin != LicenseInformationOrigin.Ignored).ToList(); } - ColumnDefinition[] relevantColumns = columnDefinitions.Where(c => c.Enabled).ToArray(); + // Apply column filtering if specified + ColumnDefinition[] relevantColumns; + if (_includedColumns.Length > 0) + { + var columnMap = columnDefinitions.ToDictionary(c => c.Title.Replace(" ", ""), StringComparer.OrdinalIgnoreCase); + var filteredColumns = new List(); + foreach (string col in _includedColumns) + { + if (columnMap.TryGetValue(col, out ColumnDefinition? columnDef)) + { + filteredColumns.Add(columnDef); + } + } + relevantColumns = filteredColumns.ToArray(); + + // If no valid columns specified, fall back to auto-detected columns + if (relevantColumns.Length == 0) + { + relevantColumns = columnDefinitions.Where(c => c.Enabled).ToArray(); + } + } + else + { + relevantColumns = columnDefinitions.Where(c => c.Enabled).ToArray(); + } + await TablePrinterExtensions .Create(stream, relevantColumns.Select(d => d.Title), _printMarkdown) .FromValues( diff --git a/src/NuGetLicense/Program.cs b/src/NuGetLicense/Program.cs index 1829e34f..0cf95ea0 100644 --- a/src/NuGetLicense/Program.cs +++ b/src/NuGetLicense/Program.cs @@ -41,7 +41,7 @@ public class Program : ICommandLineOptions [Option("-d|--license-information-download-location", Description = "Specifies a folder where the application will download all licenses provided via license URLs.")] public string? DownloadLicenseInformation { get; set; } - [Option("-o|--output", Description = "Specifies the output format. Valid values are Table, Markdown, Json, or JsonPretty (default: Table).")] + [Option("-o|--output", Description = "Specifies the output format. Valid values are Table, Markdown, Json, JsonPretty, or Csv (default: Table).")] public OutputType OutputType { get; set; } = OutputType.Table; [Option("-err|--error-only", Description = "When set, only validation errors are returned as result. Otherwise, all validation results are always returned.")] @@ -68,6 +68,9 @@ public class Program : ICommandLineOptions [Option("--exclude-publish-false", Description = "If set, packages with false metadata are excluded from analysis.")] public bool ExcludePublishFalse { get; set; } + [Option("-c|--include-columns", Description = "Specifies which columns to include in the output. Provide a semicolon-separated list of column names (e.g., \"Package;Version;License\"). Available columns: Package, Version, LicenseInformationOrigin, LicenseExpression, LicenseUrl, Copyright, Authors, PackageProjectUrl, Error, ErrorContext. If omitted, all relevant columns are shown.")] + public string? IncludedColumns { get; set; } + public async Task OnExecuteAsync(CommandLineApplication app, CancellationToken cancellationToken) { // Check if mandatory parameters are provided diff --git a/src/NuGetUtility/OutputType.cs b/src/NuGetUtility/OutputType.cs index 74525e51..3d66ac6d 100644 --- a/src/NuGetUtility/OutputType.cs +++ b/src/NuGetUtility/OutputType.cs @@ -1,4 +1,4 @@ -// Licensed to the projects contributors. +// Licensed to the projects contributors. // The license conditions are provided in the LICENSE file located in the project root namespace NuGetUtility @@ -8,6 +8,7 @@ public enum OutputType Table, Json, JsonPretty, - Markdown + Markdown, + Csv } } diff --git a/tests/NuGetUtility.UrlToLicenseMapping.Test/UrlToLicenseMappingTest.cs b/tests/NuGetUtility.UrlToLicenseMapping.Test/UrlToLicenseMappingTest.cs index 432fe8b9..9c6e3015 100644 --- a/tests/NuGetUtility.UrlToLicenseMapping.Test/UrlToLicenseMappingTest.cs +++ b/tests/NuGetUtility.UrlToLicenseMapping.Test/UrlToLicenseMappingTest.cs @@ -1,4 +1,4 @@ -// Licensed to the projects contributors. +// Licensed to the projects contributors. // The license conditions are provided in the LICENSE file located in the project root using System.Collections.Concurrent; @@ -57,7 +57,9 @@ public async Task License_Should_Be_Available_And_Match_Expected_License(KeyValu if (licenseResult.IsSuccess) { - await Verify(licenseResult.Value).HashParameters().UseStringComparer(CompareLicense); + // Verify the license content matches the expected license type from the mapping + await Verify(licenseResult.Value).HashParameters().UseStringComparer( + (received, verified, context) => CompareLicense(received, verified, context, mappedValue.Value)); runSucceeded = true; return; } @@ -113,11 +115,47 @@ private async Task> GetLicenseValue(Uri licenseUrl, DisposableWeb return new() { Value = bodyText }; } - private static Task CompareLicense(string received, string verified, IReadOnlyDictionary context) + private static Task CompareLicense(string received, string verified, IReadOnlyDictionary context, string expectedLicenseType) { string trimmedReceived = string.Join(' ', received.Split(Array.Empty(), StringSplitOptions.RemoveEmptyEntries)); string trimmedVerified = string.Join(' ', verified.Split(Array.Empty(), StringSplitOptions.RemoveEmptyEntries)); - return Task.FromResult(new CompareResult(!string.IsNullOrWhiteSpace(trimmedVerified) && trimmedReceived.Contains(trimmedVerified))); + + // First check if the received content contains the verified content + bool contentMatches = !string.IsNullOrWhiteSpace(trimmedVerified) && trimmedReceived.Contains(trimmedVerified); + + // Additionally verify that the license content matches the expected license type from the mapping + bool licenseTypeMatches = VerifyLicenseType(trimmedReceived, expectedLicenseType); + + if (!licenseTypeMatches) + { + Console.WriteLine($"License type verification failed. Expected: {expectedLicenseType}"); + Console.WriteLine($"Received content snippet: {trimmedReceived[..Math.Min(200, trimmedReceived.Length)]}..."); + } + + return Task.FromResult(new CompareResult(contentMatches && licenseTypeMatches)); + } + + private static bool VerifyLicenseType(string licenseContent, string expectedLicenseType) + { + // Normalize the content for comparison + string normalizedContent = licenseContent.ToUpperInvariant(); + + return expectedLicenseType switch + { + "MIT" => normalizedContent.Contains("MIT LICENSE") || + normalizedContent.Contains("THE MIT LICENSE"), + "Apache-2.0" => normalizedContent.Contains("APACHE LICENSE") || + normalizedContent.Contains("APACHE 2.0") || + normalizedContent.Contains("APACHE-2.0") || + normalizedContent.Contains("VERSION 2.0"), + "BSD-3-Clause" => normalizedContent.Contains("BSD") || + normalizedContent.Contains("3-CLAUSE") || + normalizedContent.Contains("REDISTRIBUTION AND USE IN SOURCE AND BINARY FORMS"), + "MS-PL" => normalizedContent.Contains("MICROSOFT") || + normalizedContent.Contains("MS-PL") || + normalizedContent.Contains("MICROSOFT PUBLIC LICENSE"), + _ => true // If we don't have specific validation for this license type, allow it to pass + }; } private sealed class DisposableWebDriver : IDisposable