Skip to content
Merged
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
90 changes: 90 additions & 0 deletions src/DemaConsulting.SarifMark/SarifResults.cs
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
// SOFTWARE.

using System.Text;
using System.Text.Json;

namespace DemaConsulting.SarifMark;
Expand Down Expand Up @@ -182,4 +183,93 @@ public static SarifResults Read(string filePath)
throw new InvalidOperationException($"Invalid JSON in SARIF file: {ex.Message}", ex);
}
}

/// <summary>
/// Converts the SARIF results to markdown format.
/// </summary>
/// <param name="depth">The heading depth level (1-6) for the report title.</param>
/// <returns>Markdown representation of the SARIF results.</returns>
/// <exception cref="ArgumentOutOfRangeException">Thrown when depth is not between 1 and 6.</exception>
public string ToMarkdown(int depth)
{
if (depth < 1 || depth > 6)
{
throw new ArgumentOutOfRangeException(nameof(depth), depth, "Depth must be between 1 and 6");
}

var heading = new string('#', depth);
var subHeadingDepth = Math.Min(depth + 1, 6);
var subHeading = new string('#', subHeadingDepth);
var sb = new StringBuilder();

AppendHeader(sb, heading);
AppendResultsSection(sb, subHeading);

return sb.ToString();
}

/// <summary>
/// Appends the header section with tool name and version.
/// </summary>
private void AppendHeader(StringBuilder sb, string heading)
{
// Add tool name and version as main heading
sb.AppendLine($"{heading} {ToolName} {ToolVersion} Analysis");
sb.AppendLine();
}

/// <summary>
/// Appends the results section with count and details.
/// </summary>
private void AppendResultsSection(StringBuilder sb, string subHeading)
{
sb.AppendLine($"{subHeading} Results");
sb.AppendLine();

sb.AppendLine(FormatFoundText(Results.Count, "result"));
sb.AppendLine();

if (Results.Count > 0)
{
foreach (var result in Results)
{
var locationInfo = FormatLocation(result.Uri, result.StartLine);
sb.AppendLine($"{locationInfo}: {result.Level} [{result.RuleId}] {result.Message}");
}

sb.AppendLine();
}
}

/// <summary>
/// Formats a count with proper pluralization and "Found" prefix.
/// </summary>
/// <param name="count">The count value.</param>
/// <param name="singularNoun">The singular form of the noun.</param>
/// <returns>Formatted text like "Found no results", "Found 1 result", or "Found 5 results".</returns>
private static string FormatFoundText(int count, string singularNoun)
{
return count switch
{
0 => $"Found no {singularNoun}s",
1 => $"Found 1 {singularNoun}",
_ => $"Found {count} {singularNoun}s"
};
}

/// <summary>
/// Formats the location information for a result.
/// </summary>
/// <param name="uri">The file URI.</param>
/// <param name="startLine">The starting line number.</param>
/// <returns>Formatted location string.</returns>
private static string FormatLocation(string? uri, int? startLine)
{
if (string.IsNullOrEmpty(uri))
{
return "(no location)";
}

return startLine.HasValue ? $"{uri}({startLine})" : uri;
}
}
170 changes: 170 additions & 0 deletions test/DemaConsulting.SarifMark.Tests/SarifResultsTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -467,4 +467,174 @@ public void SarifResults_Read_WithLocations_ReturnsResultsWithLocationData()
Assert.AreEqual("src/MyClass.cs", results.Results[0].Uri);
Assert.AreEqual(42, results.Results[0].StartLine);
}

/// <summary>
/// Test that ToMarkdown with depth 1 produces correct output.
/// </summary>
[TestMethod]
public void SarifResults_ToMarkdown_Depth1_ProducesCorrectOutput()
{
// Arrange
var resultList = new List<SarifResult>
{
new("CA1001", "warning", "Types that own disposable fields should be disposable", "src/MyClass.cs", 42),
new("CA2000", "error", "Dispose objects before losing scope", "src/Program.cs", 15)
};

var results = new SarifResults("TestTool", "1.0.0", resultList);

// Act
var markdown = results.ToMarkdown(1);

// Assert
Assert.IsNotNull(markdown);
Assert.Contains("# TestTool 1.0.0 Analysis", markdown);
Assert.Contains("## Results", markdown);
Assert.Contains("Found 2 results", markdown);
Assert.Contains("src/MyClass.cs(42): warning [CA1001] Types that own disposable fields should be disposable", markdown);
Assert.Contains("src/Program.cs(15): error [CA2000] Dispose objects before losing scope", markdown);
}

/// <summary>
/// Test that ToMarkdown with depth 3 uses correct heading levels.
/// </summary>
[TestMethod]
public void SarifResults_ToMarkdown_Depth3_UsesCorrectHeadingLevels()
{
// Arrange
var results = new SarifResults("TestTool", "1.0.0", []);

// Act
var markdown = results.ToMarkdown(3);

// Assert
Assert.Contains("### TestTool 1.0.0 Analysis", markdown);
Assert.Contains("#### Results", markdown);
}

/// <summary>
/// Test that ToMarkdown with no results shows correct message.
/// </summary>
[TestMethod]
public void SarifResults_ToMarkdown_NoResults_ShowsFoundNoResults()
{
// Arrange
var results = new SarifResults("TestTool", "1.0.0", []);

// Act
var markdown = results.ToMarkdown(1);

// Assert
Assert.Contains("Found no results", markdown);
}

/// <summary>
/// Test that ToMarkdown with one result uses singular form.
/// </summary>
[TestMethod]
public void SarifResults_ToMarkdown_OneResult_UsesSingularForm()
{
// Arrange
var resultList = new List<SarifResult>
{
new("CA1001", "warning", "Test warning", "src/Test.cs", 10)
};

var results = new SarifResults("TestTool", "1.0.0", resultList);

// Act
var markdown = results.ToMarkdown(1);

// Assert
Assert.Contains("Found 1 result", markdown);
Assert.DoesNotContain("Found 1 results", markdown);
}

/// <summary>
/// Test that ToMarkdown with depth less than 1 throws exception.
/// </summary>
[TestMethod]
public void SarifResults_ToMarkdown_DepthLessThan1_ThrowsArgumentOutOfRangeException()
{
// Arrange
var results = new SarifResults("TestTool", "1.0.0", []);

// Act & Assert
var exception = Assert.Throws<ArgumentOutOfRangeException>(() => results.ToMarkdown(0));
Assert.Contains("Depth must be between 1 and 6", exception.Message);
}

/// <summary>
/// Test that ToMarkdown with depth greater than 6 throws exception.
/// </summary>
[TestMethod]
public void SarifResults_ToMarkdown_DepthGreaterThan6_ThrowsArgumentOutOfRangeException()
{
// Arrange
var results = new SarifResults("TestTool", "1.0.0", []);

// Act & Assert
var exception = Assert.Throws<ArgumentOutOfRangeException>(() => results.ToMarkdown(7));
Assert.Contains("Depth must be between 1 and 6", exception.Message);
}

/// <summary>
/// Test that ToMarkdown with maximum depth of 6 produces correct output.
/// </summary>
[TestMethod]
public void SarifResults_ToMarkdown_Depth6_ProducesCorrectOutput()
{
// Arrange
var results = new SarifResults("TestTool", "1.0.0", []);

// Act
var markdown = results.ToMarkdown(6);

// Assert
Assert.Contains("###### TestTool 1.0.0 Analysis", markdown);
Assert.Contains("###### Results", markdown); // Capped at 6
}

/// <summary>
/// Test that ToMarkdown handles result without location information.
/// </summary>
[TestMethod]
public void SarifResults_ToMarkdown_ResultWithoutLocation_ShowsNoLocation()
{
// Arrange
var resultList = new List<SarifResult>
{
new("RULE001", "error", "Error without location", null, null)
};

var results = new SarifResults("TestTool", "1.0.0", resultList);

// Act
var markdown = results.ToMarkdown(1);

// Assert
Assert.Contains("(no location): error [RULE001] Error without location", markdown);
}

/// <summary>
/// Test that ToMarkdown handles result with URI but no line number.
/// </summary>
[TestMethod]
public void SarifResults_ToMarkdown_ResultWithUriNoLine_ShowsUriOnly()
{
// Arrange
var resultList = new List<SarifResult>
{
new("RULE002", "warning", "Warning with URI only", "src/File.cs", null)
};

var results = new SarifResults("TestTool", "1.0.0", resultList);

// Act
var markdown = results.ToMarkdown(1);

// Assert
Assert.Contains("src/File.cs: warning [RULE002] Warning with URI only", markdown);
Assert.DoesNotContain("src/File.cs(", markdown);
}
}