Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 23 additions & 3 deletions src/NuGetLicense/CommandLineOptionsParser.cs
Original file line number Diff line number Diff line change
Expand Up @@ -130,14 +130,17 @@
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")
};
}
Expand Down Expand Up @@ -173,5 +176,22 @@
}
return Array.FindAll(parts, part => part.Length > 0);
}

private string[] ParseIncludedColumns(string? value)

Check warning on line 180 in src/NuGetLicense/CommandLineOptionsParser.cs

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Make 'ParseIncludedColumns' a static method.

See more on https://sonarcloud.io/project/issues?id=sensslen_nuget-license&issues=AZ0sr_khcq4s5iMQZWeW&open=AZ0sr_khcq4s5iMQZWeW&pullRequest=494
{
if (value is null)
{
return Array.Empty<string>();
}

// 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);
}
}
}
1 change: 1 addition & 0 deletions src/NuGetLicense/ICommandLineOptions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -24,5 +24,6 @@ public interface ICommandLineOptions
public string? DestinationFile { get; }
public string? LicenseFileMappings { get; }
public bool ExcludePublishFalse { get; }
public string? IncludedColumns { get; }
}
}
2 changes: 1 addition & 1 deletion src/NuGetLicense/ICommandLineOptionsParser.cs
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,6 @@ public interface ICommandLineOptionsParser
/// <summary>
/// Gets the output formatter from the command line options.
/// </summary>
IOutputFormatter GetOutputFormatter(OutputType outputType, bool returnErrorsOnly, bool includeIgnoredPackages);
IOutputFormatter GetOutputFormatter(OutputType outputType, bool returnErrorsOnly, bool includeIgnoredPackages, string? includedColumns);
}
}
2 changes: 1 addition & 1 deletion src/NuGetLicense/LicenseValidationOrchestrator.cs
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,7 @@ public async Task<int> 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(
Expand Down
101 changes: 101 additions & 0 deletions src/NuGetLicense/Output/Csv/CsvOutputFormatter.cs
Original file line number Diff line number Diff line change
@@ -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
{
/// <summary>
/// Outputs license validation results in CSV format.
/// </summary>
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<string>();
}

public async Task Write(Stream stream, IList<LicenseValidationResult> 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<string, Func<LicenseValidationResult, string?>>(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();
}
Comment on lines +39 to +63

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

PackageProjectUrl is missing from CSV column definitions, breaking a documented include-column value.

--include-columns PackageProjectUrl currently yields no match here and can fall back to all columns, which is surprising. Program advertises this column, and table output supports it.

Proposed fix
             var allColumns = new Dictionary<string, Func<LicenseValidationResult, string?>>(StringComparer.OrdinalIgnoreCase)
             {
                 ["Package"] = r => r.PackageId,
                 ["Version"] = r => r.PackageVersion.ToString(),
                 ["LicenseInformationOrigin"] = r => r.LicenseInformationOrigin.ToString(),
                 ["LicenseExpression"] = r => r.License,
                 ["LicenseUrl"] = r => r.LicenseUrl,
+                ["PackageProjectUrl"] = r => r.PackageProjectUrl,
                 ["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
             };
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
var allColumns = new Dictionary<string, Func<LicenseValidationResult, string?>>(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();
}
var allColumns = new Dictionary<string, Func<LicenseValidationResult, string?>>(StringComparer.OrdinalIgnoreCase)
{
["Package"] = r => r.PackageId,
["Version"] = r => r.PackageVersion.ToString(),
["LicenseInformationOrigin"] = r => r.LicenseInformationOrigin.ToString(),
["LicenseExpression"] = r => r.License,
["LicenseUrl"] = r => r.LicenseUrl,
["PackageProjectUrl"] = r => r.PackageProjectUrl,
["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();
}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/NuGetLicense/Output/Csv/CsvOutputFormatter.cs` around lines 39 - 63, The
CSV column map in CsvOutputFormatter (the allColumns Dictionary<string,
Func<LicenseValidationResult, string?>>) is missing the "PackageProjectUrl" key,
so "--include-columns PackageProjectUrl" doesn't match; add an entry like
["PackageProjectUrl"] = r => r.PackageProjectUrl (or the appropriate property on
LicenseValidationResult) to the allColumns initializer so the existing
include/filter logic (_includedColumns, columnsToInclude) will recognize and
output that column consistently with other formats.


// 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();
}

/// <summary>
/// Escapes a field for CSV output.
/// Handles fields containing commas, quotes, or newlines.
/// </summary>
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;
}
}
}
31 changes: 29 additions & 2 deletions src/NuGetLicense/Output/Table/TableOutputFormatter.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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<string>();
}

public async Task Write(Stream stream, IList<LicenseValidationResult> results)
Expand Down Expand Up @@ -53,7 +55,32 @@ public async Task Write(Stream stream, IList<LicenseValidationResult> 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<ColumnDefinition>();
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(
Expand Down
5 changes: 4 additions & 1 deletion src/NuGetLicense/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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.")]
Expand All @@ -68,6 +68,9 @@ public class Program : ICommandLineOptions
[Option("--exclude-publish-false", Description = "If set, packages with <Publish>false</Publish> 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; }
Comment on lines +71 to +72

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

CLI help example uses a non-existent column token.

The example shows License, but the implemented column key is LicenseExpression. This can cause user confusion and failed filtering attempts.

Proposed wording fix
-        [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.")]
+        [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;LicenseExpression\"). Available columns: Package, Version, LicenseInformationOrigin, LicenseExpression, LicenseUrl, Copyright, Authors, PackageProjectUrl, Error, ErrorContext. If omitted, all relevant columns are shown.")]
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
[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; }
[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;LicenseExpression\"). Available columns: Package, Version, LicenseInformationOrigin, LicenseExpression, LicenseUrl, Copyright, Authors, PackageProjectUrl, Error, ErrorContext. If omitted, all relevant columns are shown.")]
public string? IncludedColumns { get; set; }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/NuGetLicense/Program.cs` around lines 71 - 72, The CLI help text for the
IncludedColumns option uses a non-existent column token "License"; update the
Option attribute Description on the IncludedColumns property so the example uses
the correct column key "LicenseExpression" (e.g.,
"Package;Version;LicenseExpression") and ensure the Available columns list still
includes LicenseExpression; modify the Description string in the IncludedColumns
property to reflect this replacement.


public async Task<int> OnExecuteAsync(CommandLineApplication app, CancellationToken cancellationToken)
{
// Check if mandatory parameters are provided
Expand Down
5 changes: 3 additions & 2 deletions src/NuGetUtility/OutputType.cs
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -8,6 +8,7 @@ public enum OutputType
Table,
Json,
JsonPretty,
Markdown
Markdown,
Csv
}
}
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -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;
}
Expand Down Expand Up @@ -113,11 +115,47 @@ private async Task<Result<string>> GetLicenseValue(Uri licenseUrl, DisposableWeb
return new() { Value = bodyText };
}

private static Task<CompareResult> CompareLicense(string received, string verified, IReadOnlyDictionary<string, object> context)
private static Task<CompareResult> CompareLicense(string received, string verified, IReadOnlyDictionary<string, object> context, string expectedLicenseType)
{
string trimmedReceived = string.Join(' ', received.Split(Array.Empty<char>(), StringSplitOptions.RemoveEmptyEntries));
string trimmedVerified = string.Join(' ', verified.Split(Array.Empty<char>(), 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
Expand Down