< Summary - Kestrun — Combined Coverage

Information
Class: Kestrun.Tool.ConsoleProgressBar
Assembly: Kestrun.Tool
File(s): /home/runner/work/Kestrun/Kestrun/src/CSharp/Kestrun.Tool/ConsoleBar.cs
Tag: Kestrun/Kestrun@6135d944f8787fb570e4dfbacac6e80312799a86
Line coverage
62%
Covered lines: 60
Uncovered lines: 36
Coverable lines: 96
Total lines: 243
Line coverage: 62.5%
Branch coverage
46%
Covered branches: 29
Total branches: 62
Branch coverage: 46.7%
Method coverage

Feature is only available for sponsors

Upgrade to PRO version

Coverage history

Coverage history 0 25 50 75 100 03/26/2026 - 03:54:59 Line coverage: 60.3% (35/58) Branch coverage: 40.9% (18/44) Total lines: 120 Tag: Kestrun/Kestrun@844b5179fb0492dc6b1182bae3ff65fa7365521d04/03/2026 - 00:32:29 Line coverage: 62.5% (60/96) Branch coverage: 46.7% (29/62) Total lines: 243 Tag: Kestrun/Kestrun@8b1d7be6fa3c9196a4dc338b779df2907d8580a4 03/26/2026 - 03:54:59 Line coverage: 60.3% (35/58) Branch coverage: 40.9% (18/44) Total lines: 120 Tag: Kestrun/Kestrun@844b5179fb0492dc6b1182bae3ff65fa7365521d04/03/2026 - 00:32:29 Line coverage: 62.5% (60/96) Branch coverage: 46.7% (29/62) Total lines: 243 Tag: Kestrun/Kestrun@8b1d7be6fa3c9196a4dc338b779df2907d8580a4

Coverage delta

Coverage delta 6 -6

Metrics

MethodBranch coverage Crap Score Cyclomatic complexity Line coverage
.ctor(...)100%44100%
.cctor()100%11100%
Report(...)12.5%1961611.11%
Complete(...)33.33%19628.57%
Dispose()50%7666.67%
GetPercent(...)100%44100%
Render(...)33.33%6692.86%
BuildRenderedLine(...)75%8888.24%
FitSimpleLineToWidth(...)0%2040%
TrimProgressText(...)66.67%7671.43%
GetUsableConsoleWidth()100%2271.43%
BuildBarWithWidth(...)100%11100%

File(s)

/home/runner/work/Kestrun/Kestrun/src/CSharp/Kestrun.Tool/ConsoleBar.cs

#LineLine coverage
 1namespace Kestrun.Tool;
 2
 3/// <summary>
 4/// Writes in-place progress updates for module operations.
 5/// </summary>
 56internal sealed class ConsoleProgressBar(string label, long? total, Func<long, long?, string>? detailFormatter) : IDispo
 7{
 8    private const int ProgressBarWidth = 28;
 9    private const int MinimumBarWidth = 8;
 110    private static readonly TimeSpan RenderThrottle = TimeSpan.FromMilliseconds(80);
 11
 512    private readonly string _label = label;
 513    private readonly long? _total = total.HasValue && total.Value > 0 ? total : null;
 514    private readonly Func<long, long?, string>? _detailFormatter = detailFormatter;
 515    private readonly bool _enabled = !Console.IsOutputRedirected;
 16    private int _lastRenderedLength;
 517    private int _lastPercent = -1;
 518    private long _lastValue = -1;
 519    private DateTime _lastRenderUtc = DateTime.MinValue;
 20    private bool _hasRendered;
 21    private bool _isComplete;
 22
 23    public void Report(long value, bool force = false)
 24    {
 125        if (!_enabled || _isComplete)
 26        {
 127            return;
 28        }
 29
 030        var sanitizedValue = Math.Max(0, value);
 031        var percent = GetPercent(sanitizedValue);
 032        var now = DateTime.UtcNow;
 33
 034        if (!force
 035            && sanitizedValue == _lastValue
 036            && percent == _lastPercent)
 37        {
 038            return;
 39        }
 40
 041        if (!force
 042            && percent == _lastPercent
 043            && now - _lastRenderUtc < RenderThrottle)
 44        {
 045            return;
 46        }
 47
 048        _lastValue = sanitizedValue;
 049        _lastPercent = percent;
 050        _lastRenderUtc = now;
 051        Render(sanitizedValue, percent);
 052    }
 53
 54    public void Complete(long value)
 55    {
 156        if (!_enabled || _isComplete)
 57        {
 158            return;
 59        }
 60
 061        var completionValue = _total ?? Math.Max(0, value);
 062        Report(completionValue, force: true);
 063        Console.WriteLine();
 064        _isComplete = true;
 065    }
 66
 67    public void Dispose()
 68    {
 169        if (_enabled && _hasRendered && !_isComplete)
 70        {
 071            Console.WriteLine();
 72        }
 173    }
 74
 75    /// <summary>
 76    /// Returns the percentage (0-100) for the given value based on the total, or -1 if percentage cannot be calculated.
 77    /// </summary>
 78    /// <param name="value">The current value.</param>
 79    /// <returns>The percentage (0-100) or -1 if percentage cannot be calculated.</returns>
 80    private int GetPercent(long value)
 81    {
 382        if (!_total.HasValue || _total.Value <= 0)
 83        {
 184            return -1;
 85        }
 86        // Use long math to avoid overflow, then clamp to 0-100.
 287        return (int)Math.Clamp(value * 100L / _total.Value, 0, 100);
 88    }
 89
 90    /// <summary>
 91    /// Renders the progress bar line based on the current value and percentage.
 92    /// </summary>
 93    /// <param name="value">The current value.</param>
 94    /// <param name="percent">The current percentage (0-100) or -1 if percentage cannot be calculated.</param>
 95    private void Render(long value, int percent)
 96    {
 197        var detail = _detailFormatter is null
 198            ? _total.HasValue
 199                ? $"{value}/{_total.Value}"
 1100                : value.ToString()
 1101            : _detailFormatter(value, _total);
 102
 1103        var line = BuildRenderedLine(detail, percent, GetUsableConsoleWidth());
 104
 1105        var paddedLength = Math.Min(_lastRenderedLength, Math.Max(0, GetUsableConsoleWidth()));
 1106        if (line.Length < paddedLength)
 107        {
 0108            line = line.PadRight(paddedLength);
 109        }
 110
 1111        _lastRenderedLength = line.Length;
 1112        _hasRendered = true;
 113
 1114        Console.Write('\r');
 1115        Console.Write(line);
 1116    }
 117
 118    /// <summary>
 119    /// Builds the progress bar line with the given detail, percentage, and usable width. It will attempt to fit the lab
 120    /// </summary>
 121    /// <param name="detail">The detail text to display on the right side of the progress bar.</param>
 122    /// <param name="percent">The current percentage (0-100) or -1 if percentage cannot be calculated.</param>
 123    /// <param name="usableWidth">The total usable width of the console for rendering the progress bar line.</param>
 124    /// <returns>The constructed progress bar line that fits within the usable width.</returns>
 125    private string BuildRenderedLine(string detail, int percent, int usableWidth)
 126    {
 2127        if (percent < 0)
 128        {
 0129            return FitSimpleLineToWidth(_label, detail, usableWidth);
 130        }
 131
 2132        var percentText = $"{percent,3}%";
 2133        var barWidth = ProgressBarWidth;
 2134        var line = $"{_label} {BuildBarWithWidth(percent, barWidth)} {percentText} {detail}";
 2135        if (line.Length <= usableWidth)
 136        {
 0137            return line;
 138        }
 139
 2140        var reservedWithoutDetail = _label.Length + 1 + 2 + barWidth + 1 + percentText.Length + 1;
 2141        var maxDetailLength = usableWidth - reservedWithoutDetail;
 2142        if (maxDetailLength < 0)
 143        {
 1144            var availableBarWidth = usableWidth - (_label.Length + 1 + 2 + 1 + percentText.Length + 1);
 1145            barWidth = Math.Clamp(availableBarWidth, MinimumBarWidth, ProgressBarWidth);
 1146            reservedWithoutDetail = _label.Length + 1 + 2 + barWidth + 1 + percentText.Length + 1;
 1147            maxDetailLength = usableWidth - reservedWithoutDetail;
 148        }
 149
 2150        var shortenedDetail = TrimProgressText(detail, maxDetailLength);
 2151        line = $"{_label} {BuildBarWithWidth(percent, barWidth)} {percentText} {shortenedDetail}".TrimEnd();
 152        // If the line is still too long, we will trim the detail completely.
 2153        return line.Length <= usableWidth ? line : line[..Math.Max(0, usableWidth)];
 154    }
 155    /// <summary>
 156    /// Fits a simple line with a label and detail to the usable width by trimming the detail if necessary.
 157    /// The label will be preserved as much as possible, and the detail will be trimmed with ellipsis if it cannot fit.
 158    /// If the label itself cannot fit, it will be trimmed without ellipsis.
 159    /// </summary>
 160    /// <param name="label">The label text to display on the left side of the line.</param>
 161    /// <param name="detail">The detail text to display on the right side of the line.</param>
 162    /// <param name="usableWidth">The total usable width of the console for rendering the line.</param>
 163    /// <returns>The constructed line that fits within the usable width.</returns>
 164    private static string FitSimpleLineToWidth(string label, string detail, int usableWidth)
 165    {
 0166        var line = $"{label} {detail}".TrimEnd();
 0167        if (line.Length <= usableWidth)
 168        {
 0169            return line;
 170        }
 171
 0172        var maxDetailLength = usableWidth - label.Length - 1;
 173        // If we cannot fit any detail, just return the label trimmed to the usable width.
 0174        if (maxDetailLength <= 0)
 175        {
 0176            return label[..Math.Min(label.Length, Math.Max(0, usableWidth))];
 177        }
 178        // If detail cannot fit, we will trim it with ellipsis if possible. If even the ellipsis cannot fit, we will jus
 0179        return $"{label} {TrimProgressText(detail, maxDetailLength)}".TrimEnd();
 180    }
 181
 182    /// <summary>
 183    /// Trims the given text to fit within the max length by adding ellipsis if it exceeds the max length.
 184    /// If the max length is too small to fit any text, it will return an empty string or a truncated string without ell
 185    /// </summary>
 186    /// <param name="text">The text to be trimmed.</param>
 187    /// <param name="maxLength">The maximum allowed length for the text.</param>
 188    /// <returns>The trimmed text, possibly with ellipsis.</returns>
 189    private static string TrimProgressText(string text, int maxLength)
 190    {
 191        // If maxLength is zero or negative, we cannot show any text.
 2192        if (maxLength <= 0)
 193        {
 1194            return string.Empty;
 195        }
 196        // If the text fits within the max length, return it as is.
 1197        if (text.Length <= maxLength)
 198        {
 0199            return text;
 200        }
 201        // If the text is too long, trim it and add ellipsis. Ensure that the total length does not exceed maxLength.
 1202        if (maxLength <= 3)
 203        {
 0204            return text[..maxLength];
 205        }
 206        // Trim the text to fit within maxLength, accounting for the length of the ellipsis.
 1207        return $"{text[..(maxLength - 3)]}...";
 208    }
 209
 210    private static int GetUsableConsoleWidth()
 211    {
 212        try
 213        {
 2214            var width = Console.WindowWidth;
 2215            if (width <= 1)
 216            {
 2217                width = Console.BufferWidth;
 218            }
 219
 2220            return Math.Max(1, width - 1);
 221        }
 0222        catch
 223        {
 0224            return 120;
 225        }
 2226    }
 227
 228    /// <summary>
 229    /// Builds a progress bar string with the specified width. The bar is filled proportionally to the given percentage,
 230    /// The total width of the bar is determined by the 'width' parameter, and the filled portion is calculated based on
 231    /// for a 50% completion with a width of 10.
 232    /// </summary>
 233    /// <param name="percent">Progress percentage (0-100).</param>
 234    /// <param name="width">Total width of the progress bar.</param>
 235    /// <returns>Progress bar text enclosed in brackets.</returns>
 236    private static string BuildBarWithWidth(int percent, int width)
 237    {
 7238        var normalizedWidth = Math.Max(1, width);
 7239        var filled = (int)Math.Round(percent / 100d * normalizedWidth, MidpointRounding.AwayFromZero);
 7240        filled = Math.Clamp(filled, 0, normalizedWidth);
 7241        return $"[{new string('#', filled)}{new string('-', normalizedWidth - filled)}]";
 242    }
 243}