Editor utility that exports Asset Doctor scan results to timestamped Markdown, plain-text, and JSON files. It orders findings by severity and writes safe temporary files then atomically moves them to final filenames, with sanitization and simple escaping for Markdown and JSON.
#nullable enable
using System;
using System.Collections.Generic;
using System.Globalization;
using System.IO;
using System.Linq;
using System.Text;
namespace AssetDoctor;
/// <summary>Writes timestamped Markdown, text, and JSON reports for completed heuristic scans.</summary>
public static class AssetDoctorReportExporter
{
/// <summary>Exports all report formats with one timestamp and attempts to remove partial final files on failure.</summary>
public static IReadOnlyList<string> Export(string directory, string projectName, IReadOnlyCollection<Finding> findings)
{
if(string.IsNullOrWhiteSpace(directory)) throw new ArgumentException("A report directory is required.", nameof(directory));
if(findings == null) throw new ArgumentNullException(nameof(findings));
Directory.CreateDirectory(directory);
var generatedAt = DateTimeOffset.UtcNow;
var stamp = generatedAt.ToString("yyyyMMdd-HHmmss-fff", CultureInfo.InvariantCulture);
var safeProject = SanitizeFileName(string.IsNullOrWhiteSpace(projectName) ? "project" : projectName);
var unique = Guid.NewGuid().ToString("N")[..8];
var prefix = $"asset_doctor_{safeProject}_{stamp}_{unique}";
var ordered = findings.OrderByDescending(x => Rank(x.Severity)).ThenBy(x => x.RuleId, StringComparer.Ordinal).ThenBy(x => x.SourcePath, StringComparer.Ordinal).ToArray();
var finals = new[] { Path.Combine(directory, prefix + ".md"), Path.Combine(directory, prefix + ".txt"), Path.Combine(directory, prefix + ".json") };
var temps = finals.Select(path => path + ".tmp-" + Guid.NewGuid().ToString("N")).ToArray();
var moved = new List<string>();
try
{
WriteMarkdown(temps[0], ordered, generatedAt);
WriteText(temps[1], ordered, generatedAt);
WriteJson(temps[2], ordered, generatedAt);
for(var index = 0; index < finals.Length; index++)
{
File.Move(temps[index], finals[index], false);
moved.Add(finals[index]);
}
return finals;
}
catch
{
foreach(var path in moved) TryDelete(path);
throw;
}
finally
{
foreach(var path in temps) TryDelete(path);
}
}
/// <summary>Writes a Markdown report without building an additional full report string in memory.</summary>
private static void WriteMarkdown(string path, IReadOnlyList<Finding> findings, DateTimeOffset generatedAt)
{
using var writer = CreateWriter(path);
writer.WriteLine("# Asset Doctor Report");
writer.WriteLine();
writer.WriteLine($"Generated: {generatedAt:O}");
writer.WriteLine("Detection mode: heuristic quoted-path scan; results are not a complete dependency graph.");
writer.WriteLine($"Issues: {findings.Count}");
writer.WriteLine();
foreach(var finding in findings)
{
writer.WriteLine($"## {EscapeMarkdown(finding.RuleId)} · {finding.Severity}");
writer.WriteLine();
writer.WriteLine(EscapeMarkdown(finding.Message));
writer.WriteLine($"- Source: {Code(finding.SourcePath)}");
if(!string.IsNullOrEmpty(finding.ReferencedPath)) writer.WriteLine($"- Reference: {Code(finding.ReferencedPath)}");
if(finding.Line.HasValue) writer.WriteLine($"- Line: {finding.Line.Value}");
if(!string.IsNullOrEmpty(finding.Details)) writer.WriteLine($"- Details: {Code(finding.Details)}");
writer.WriteLine();
}
}
/// <summary>Writes a plain-text report without building an additional full report string in memory.</summary>
private static void WriteText(string path, IReadOnlyList<Finding> findings, DateTimeOffset generatedAt)
{
using var writer = CreateWriter(path);
writer.WriteLine("ASSET DOCTOR REPORT");
writer.WriteLine($"Generated: {generatedAt:O}");
writer.WriteLine("Detection mode: heuristic quoted-path scan; results are not a complete dependency graph.");
writer.WriteLine($"Issues: {findings.Count}");
writer.WriteLine();
foreach(var finding in findings)
{
writer.WriteLine($"{finding.RuleId} · {finding.Severity} · {Plain(finding.Message)}");
writer.WriteLine($"Source: {Plain(finding.SourcePath)}");
if(!string.IsNullOrEmpty(finding.ReferencedPath)) writer.WriteLine($"Reference: {Plain(finding.ReferencedPath)}");
if(finding.Line.HasValue) writer.WriteLine($"Line: {finding.Line.Value}");
if(!string.IsNullOrEmpty(finding.Details)) writer.WriteLine($"Details: {Plain(finding.Details)}");
writer.WriteLine();
}
}
/// <summary>Writes a machine-readable JSON report without requiring external serializer packages.</summary>
private static void WriteJson(string path, IReadOnlyList<Finding> findings, DateTimeOffset generatedAt)
{
using var writer = CreateWriter(path);
writer.WriteLine("{");
writer.WriteLine($" \"generatedAt\": {Json(generatedAt.ToString("O", CultureInfo.InvariantCulture))},");
writer.WriteLine(" \"detectionMode\": \"heuristic quoted-path scan; not a complete dependency graph\",");
writer.WriteLine(" \"findings\": [");
for(var index = 0; index < findings.Count; index++)
{
var finding = findings[index];
writer.Write(" { \"ruleId\": "); writer.Write(Json(finding.RuleId));
writer.Write(", \"severity\": "); writer.Write(Json(finding.Severity.ToString()));
writer.Write(", \"message\": "); writer.Write(Json(finding.Message));
writer.Write(", \"sourcePath\": "); writer.Write(Json(finding.SourcePath));
writer.Write(", \"referencedPath\": "); writer.Write(Json(finding.ReferencedPath));
writer.Write(", \"line\": "); writer.Write(finding.Line?.ToString(CultureInfo.InvariantCulture) ?? "null");
writer.Write(", \"details\": "); writer.Write(Json(finding.Details));
writer.Write(" }");
if(index + 1 < findings.Count) writer.Write(',');
writer.WriteLine();
}
writer.WriteLine(" ]");
writer.WriteLine("}");
}
/// <summary>Creates a UTF-8 writer for one temporary report.</summary>
private static StreamWriter CreateWriter(string path) => new(path, false, new UTF8Encoding(false));
/// <summary>Escapes a JSON string including control characters.</summary>
private static string Json(string? value)
{
if(value == null) return "null";
var builder = new StringBuilder(value.Length + 2).Append('"');
foreach(var character in value)
{
switch(character)
{
case '\\': builder.Append("\\\\"); break;
case '"': builder.Append("\\\""); break;
case '\b': builder.Append("\\b"); break;
case '\f': builder.Append("\\f"); break;
case '\n': builder.Append("\\n"); break;
case '\r': builder.Append("\\r"); break;
case '\t': builder.Append("\\t"); break;
default:
if(character < ' ') builder.Append($"\\u{(int)character:X4}");
else builder.Append(character);
break;
}
}
return builder.Append('"').ToString();
}
/// <summary>Escapes Markdown characters that could alter report structure.</summary>
private static string EscapeMarkdown(string? value) => Plain(value).Replace("\\", "\\\\").Replace("`", "\\`").Replace("*", "\\*").Replace("_", "\\_").Replace("[", "\\[").Replace("]", "\\]").Replace("#", "\\#").Replace("|", "\\|").Replace("!", "\\!").Replace("~", "\\~").Replace("<", "<").Replace(">", ">");
/// <summary>Uses a variable-length inline-code delimiter so embedded backticks remain literal.</summary>
private static string Code(string? value)
{
var text = Plain(value);
var fence = "`";
while(text.Contains(fence, StringComparison.Ordinal)) fence += "`";
return fence + text + fence;
}
/// <summary>Renders selected control and directional characters visibly to reduce report spoofing.</summary>
private static string Plain(string? value)
{
if(string.IsNullOrEmpty(value)) return string.Empty;
var builder = new StringBuilder(value.Length);
foreach(var character in value) builder.Append(char.IsControl(character) || character == '\u202E' ? $"\\u{(int)character:X4}" : character);
return builder.ToString();
}
/// <summary>Replaces filename characters that are invalid on the current platform.</summary>
private static string SanitizeFileName(string value) => string.Concat(value.Select(character => Path.GetInvalidFileNameChars().Contains(character) ? '_' : character));
/// <summary>Deletes temporary or rolled-back files without masking the primary export error.</summary>
private static void TryDelete(string path)
{
try { if(File.Exists(path)) File.Delete(path); }
catch { }
}
/// <summary>Returns a deterministic severity sort rank.</summary>
private static int Rank(FindingSeverity severity) => severity == FindingSeverity.Error ? 2 : severity == FindingSeverity.Warning ? 1 : 0;
}