diff --git a/Buildvana.slnx b/Buildvana.slnx index 199282f..db368d7 100644 --- a/Buildvana.slnx +++ b/Buildvana.slnx @@ -39,6 +39,7 @@ + diff --git a/CHANGELOG.md b/CHANGELOG.md index 3cc68df..bb1dd14 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -54,6 +54,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - `diagnostic` (or `diag`) Cake verbosity values (e.g., `verbose`) are no longer accepted. +- `bv` no longer prefixes its console output with a log level and a class-name category (e.g. `info: Buildvana.Tool.Services.DotNetService: ...`). Messages now render as clean, color-coded lines: errors in red and warnings in yellow, each line tagged with a short level label (`error:`/`warning:`/`info:`/`detail:`/`trace:`). In addition, `dotnet`/MSBuild output is now streamed through live (standard output to `bv`'s standard output, standard error to its standard error) instead of being hidden unless the build fails; on failure, the first and last lines of the captured output are still included in the error message. Verbosity behavior is unchanged (`--verbosity quiet|minimal|normal|detailed|diagnostic`), as is the handling of `--color`/`--no-color` (with the [`NO_COLOR` environment variable](https://no-color.org) now honored as well). - **BREAKING CHANGE**: `bv restore`, `bv build`, `bv test`, and `bv pack` forward extra command-line arguments to the underlying `dotnet` invocation(s) only after a `--` separator: everything after the first `--` is passed through verbatim, in the order given, and `bv` no longer parses or validates it. A non-global, option-looking token _before_ `--` is now an error that points you at the separator. Malformed or unknown forwarded arguments produce an error from `dotnet` (or, for `bv test`, from the Microsoft.Testing.Platform test application) rather than from `bv`. Previously only `-p:`/`/p:` MSBuild properties were forwarded. `bv` also always forwards `--nologo` and its resolved `--verbosity` (default `normal`) to those invocations. - `bv build -- -m:8 -v:minimal` forwards `-m:8 -v:minimal` to `dotnet build`. - `bv test -- --report-trx` reaches the test application. diff --git a/Directory.Packages.props b/Directory.Packages.props index 5244ef3..d7d81fa 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -45,7 +45,6 @@ - diff --git a/src/Buildvana.Core.Abstractions/Buildvana.Core.Abstractions.csproj b/src/Buildvana.Core.Abstractions/Buildvana.Core.Abstractions.csproj index 4573467..d49630b 100644 --- a/src/Buildvana.Core.Abstractions/Buildvana.Core.Abstractions.csproj +++ b/src/Buildvana.Core.Abstractions/Buildvana.Core.Abstractions.csproj @@ -10,7 +10,6 @@ - diff --git a/src/Buildvana.Core.Abstractions/ConsoleOutput/IActivityScope.cs b/src/Buildvana.Core.Abstractions/ConsoleOutput/IActivityScope.cs new file mode 100644 index 0000000..4979dca --- /dev/null +++ b/src/Buildvana.Core.Abstractions/ConsoleOutput/IActivityScope.cs @@ -0,0 +1,25 @@ +// Copyright (C) Tenacom and Contributors. Licensed under the MIT license. +// See the LICENSE file in the project root for full license information. + +using System; + +namespace Buildvana.Core.ConsoleOutput; + +/// +/// Represents an open activity: a "begin → detail → outcome" grouping of related work started by +/// . +/// +/// +/// Use with a using statement and call as the last statement of the block. The +/// outcome line is reported only when ran before disposal; if the scope is disposed +/// without completing (for example because the work threw), nothing is reported — much like a transaction that +/// is rolled back when not committed. +/// +public interface IActivityScope : IDisposable +{ + /// + /// Marks the activity as successfully completed, so that disposing the scope reports its outcome. + /// + /// An optional message describing the outcome of the activity. + void Complete(string? outcomeMessage = null); +} diff --git a/src/Buildvana.Core.Abstractions/ConsoleOutput/IReporter.cs b/src/Buildvana.Core.Abstractions/ConsoleOutput/IReporter.cs new file mode 100644 index 0000000..c63937c --- /dev/null +++ b/src/Buildvana.Core.Abstractions/ConsoleOutput/IReporter.cs @@ -0,0 +1,51 @@ +// Copyright (C) Tenacom and Contributors. Licensed under the MIT license. +// See the LICENSE file in the project root for full license information. + +namespace Buildvana.Core.ConsoleOutput; + +/// +/// Reports human-facing console output: leveled messages, activity grouping, and verbatim child-process +/// passthrough. This is the primitive surface; formatting and per-level convenience helpers are provided as +/// extension methods (see ). +/// +public interface IReporter +{ + /// + /// Gets the verbosity that gates which s are rendered. + /// + Verbosity Verbosity { get; } + + /// + /// Reports a single message at the given . The message is rendered only when + /// is enabled by the current . + /// + /// The severity of the message. + /// The message text. + void Report(MessageLevel level, string message); + + /// + /// Begins an activity: a header is rendered now, and the returned scope renders the outcome (and elapsed + /// time) when disposed. + /// + /// A short description of the work the activity covers. + /// A scope that closes the activity when disposed. + IActivityScope BeginActivity(string title); + + /// + /// Writes a line from a child process's standard output verbatim: no level label, no color, no category. + /// Used to stream a spawned process's standard output through to this process's standard output. + /// + /// The line of child-process standard output to write. + /// The line is written only when the reporter's is at + /// least this value. If , the line is always written. + void ChildOutput(string line, Verbosity? minimumVerbosity); + + /// + /// Writes a line from a child process's standard error verbatim: no level label, no color, no category. + /// Used to stream a spawned process's standard error through to this process's standard error. + /// + /// The line of child-process standard error to write. + /// The line is written only when the reporter's is at + /// least this value. If , the line is always written. + void ChildError(string line, Verbosity? minimumVerbosity); +} diff --git a/src/Buildvana.Core.Abstractions/ConsoleOutput/MessageLevel.cs b/src/Buildvana.Core.Abstractions/ConsoleOutput/MessageLevel.cs new file mode 100644 index 0000000..df74212 --- /dev/null +++ b/src/Buildvana.Core.Abstractions/ConsoleOutput/MessageLevel.cs @@ -0,0 +1,32 @@ +// Copyright (C) Tenacom and Contributors. Licensed under the MIT license. +// See the LICENSE file in the project root for full license information. + +namespace Buildvana.Core.ConsoleOutput; + +/// +/// The severity of a message reported through an . The level both classifies the +/// message and, together with the reporter's , decides whether it is shown. +/// +/// +/// Members are ordered from highest to lowest severity, mapping one-to-one onto the +/// thresholds: , , +/// , , +/// . +/// +public enum MessageLevel +{ + /// An error: something went wrong. Shown at every verbosity. + Error, + + /// A warning: something looks off but is not fatal. + Warning, + + /// An informational milestone. Shown at and above. + Info, + + /// A detail useful when following along closely. Shown at and above. + Detail, + + /// Fine-grained diagnostic chatter. Shown only at . + Trace, +} diff --git a/src/Buildvana.Core.Abstractions/ConsoleOutput/NullActivityScope.cs b/src/Buildvana.Core.Abstractions/ConsoleOutput/NullActivityScope.cs new file mode 100644 index 0000000..75d0248 --- /dev/null +++ b/src/Buildvana.Core.Abstractions/ConsoleOutput/NullActivityScope.cs @@ -0,0 +1,30 @@ +// Copyright (C) Tenacom and Contributors. Licensed under the MIT license. +// See the LICENSE file in the project root for full license information. + +namespace Buildvana.Core.ConsoleOutput; + +/// +/// An that does nothing. Returned by and usable +/// anywhere a non-rendering activity scope is needed. +/// +public sealed class NullActivityScope : IActivityScope +{ + private NullActivityScope() + { + } + + /// + /// Gets the singleton instance. + /// + public static NullActivityScope Instance { get; } = new(); + + /// + public void Complete(string? outcomeMessage = null) + { + } + + /// + public void Dispose() + { + } +} diff --git a/src/Buildvana.Core.Abstractions/ConsoleOutput/NullReporter.cs b/src/Buildvana.Core.Abstractions/ConsoleOutput/NullReporter.cs new file mode 100644 index 0000000..6071f98 --- /dev/null +++ b/src/Buildvana.Core.Abstractions/ConsoleOutput/NullReporter.cs @@ -0,0 +1,41 @@ +// Copyright (C) Tenacom and Contributors. Licensed under the MIT license. +// See the LICENSE file in the project root for full license information. + +namespace Buildvana.Core.ConsoleOutput; + +/// +/// An that discards everything. Useful as a default argument or in tests that do not +/// assert on output. +/// +public sealed class NullReporter : IReporter +{ + private NullReporter() + { + } + + /// + /// Gets the singleton instance. + /// + public static NullReporter Instance { get; } = new(); + + /// + public Verbosity Verbosity => Verbosity.Quiet; + + /// + public void Report(MessageLevel level, string message) + { + } + + /// + public IActivityScope BeginActivity(string title) => NullActivityScope.Instance; + + /// + public void ChildOutput(string line, Verbosity? minimumVerbosity) + { + } + + /// + public void ChildError(string line, Verbosity? minimumVerbosity) + { + } +} diff --git a/src/Buildvana.Core.Abstractions/ConsoleOutput/ReporterExtensions.cs b/src/Buildvana.Core.Abstractions/ConsoleOutput/ReporterExtensions.cs new file mode 100644 index 0000000..6ca74fc --- /dev/null +++ b/src/Buildvana.Core.Abstractions/ConsoleOutput/ReporterExtensions.cs @@ -0,0 +1,111 @@ +// Copyright (C) Tenacom and Contributors. Licensed under the MIT license. +// See the LICENSE file in the project root for full license information. + +using System; +using System.Globalization; +using System.Text; + +namespace Buildvana.Core.ConsoleOutput; + +/// +/// Provides extension methods for instances: per-level message shortcuts and +/// -based formatting overloads. +/// +/// +/// All formatting uses . Cache the +/// instances passed to the formatting overloads in static readonly fields at the call site. +/// +#pragma warning disable CA1034 // Nested types should not be visible — false positive on C# 14 extension blocks; fixed in .NET 11, backport to .NET 10 requested in https://github.com/dotnet/sdk/issues/53984 +#pragma warning disable CA1708 // Identifiers should differ by more than case — false positive on classes with C# 14 extension blocks; fixed in .NET 11, https://github.com/dotnet/sdk/issues/51716 +public static class ReporterExtensions +{ + extension(IReporter @this) + { + /// Reports an message. + /// The message text. + public void Error(string message) => @this.Report(MessageLevel.Error, message); + + /// Reports a message. + /// The message text. + public void Warning(string message) => @this.Report(MessageLevel.Warning, message); + + /// Reports an message. + /// The message text. + public void Info(string message) => @this.Report(MessageLevel.Info, message); + + /// Reports a message. + /// The message text. + public void Detail(string message) => @this.Report(MessageLevel.Detail, message); + + /// Reports a message. + /// The message text. + public void Trace(string message) => @this.Report(MessageLevel.Trace, message); + + /// Formats and reports an message. + /// The composite format string. + /// The arguments to format. + public void Error(CompositeFormat format, params ReadOnlySpan args) + => @this.Report(MessageLevel.Error, format, args); + + /// Formats and reports a message. + /// The composite format string. + /// The arguments to format. + public void Warning(CompositeFormat format, params ReadOnlySpan args) + => @this.Report(MessageLevel.Warning, format, args); + + /// Formats and reports an message. + /// The composite format string. + /// The arguments to format. + public void Info(CompositeFormat format, params ReadOnlySpan args) + => @this.Report(MessageLevel.Info, format, args); + + /// Formats and reports a message. + /// The composite format string. + /// The arguments to format. + public void Detail(CompositeFormat format, params ReadOnlySpan args) + => @this.Report(MessageLevel.Detail, format, args); + + /// Formats and reports a message. + /// The composite format string. + /// The arguments to format. + public void Trace(CompositeFormat format, params ReadOnlySpan args) + => @this.Report(MessageLevel.Trace, format, args); + + /// + /// Formats and reports a message at the given . Formatting is skipped entirely + /// when is not enabled by the reporter's . + /// + /// The severity of the message. + /// The composite format string. + /// The arguments to format. + public void Report(MessageLevel level, CompositeFormat format, params ReadOnlySpan args) + { + ArgumentNullException.ThrowIfNull(format); + if (!@this.IsEnabled(level)) + { + return; + } + + @this.Report(level, string.Format(CultureInfo.InvariantCulture, format, args)); + } + + /// + /// Determines whether a message at the given would be rendered at the + /// reporter's current . + /// + /// The level to test. + /// if the level is enabled; otherwise, . + public bool IsEnabled(MessageLevel level) => (int)level <= (int)@this.Verbosity; + + /// + /// Determines whether the reporter's is at least the given + /// . + /// + /// The minimum verbosity to test against. + /// + /// if the reporter's verbosity is at least ; + /// otherwise, . + /// + public bool IsVerbosityAtLeast(Verbosity minimumVerbosity) => (int)minimumVerbosity <= (int)@this.Verbosity; + } +} diff --git a/src/Buildvana.Core.Abstractions/ConsoleOutput/Verbosity.cs b/src/Buildvana.Core.Abstractions/ConsoleOutput/Verbosity.cs new file mode 100644 index 0000000..d468d59 --- /dev/null +++ b/src/Buildvana.Core.Abstractions/ConsoleOutput/Verbosity.cs @@ -0,0 +1,31 @@ +// Copyright (C) Tenacom and Contributors. Licensed under the MIT license. +// See the LICENSE file in the project root for full license information. + +namespace Buildvana.Core.ConsoleOutput; + +/// +/// Controls how much of a reporter's output reaches the user. Each level enables all the +/// s enabled by the levels below it (see for the mapping). +/// +/// +/// The members mirror bv's --verbosity command-line vocabulary and are ordered from least to most +/// verbose, so a message at a given is shown when +/// (int)level <= (int)verbosity. +/// +public enum Verbosity +{ + /// Only errors are shown. + Quiet, + + /// Errors and warnings are shown. + Minimal, + + /// Errors, warnings, and informational messages are shown. This is the default. + Normal, + + /// Everything shows, plus detail messages. + Detailed, + + /// Everything is shown, including trace messages. + Diagnostic, +} diff --git a/src/Buildvana.Core.Abstractions/Process/IProcessRunner.cs b/src/Buildvana.Core.Abstractions/Process/IProcessRunner.cs index 36fcdc7..9f70ed4 100644 --- a/src/Buildvana.Core.Abstractions/Process/IProcessRunner.cs +++ b/src/Buildvana.Core.Abstractions/Process/IProcessRunner.cs @@ -22,6 +22,8 @@ public interface IProcessRunner /// If (the default), a is thrown when the process exits with a non-zero exit code; if , the result is returned regardless of exit code. /// An optional callback invoked once per line of standard output as it is produced. /// The full output text is captured into the returned regardless. + /// An optional callback invoked once per line of standard error as it is produced. + /// The full error text is captured into the returned regardless. /// A used to cancel the process. /// A describing the process's outcome. Task RunAsync( @@ -30,5 +32,6 @@ Task RunAsync( string? workingDirectory = null, bool throwOnNonZero = true, Action? onStdout = null, + Action? onStderr = null, CancellationToken cancellationToken = default); } diff --git a/src/Buildvana.Core.ConsoleOutput/Buildvana.Core.ConsoleOutput.csproj b/src/Buildvana.Core.ConsoleOutput/Buildvana.Core.ConsoleOutput.csproj new file mode 100644 index 0000000..9cd72ed --- /dev/null +++ b/src/Buildvana.Core.ConsoleOutput/Buildvana.Core.ConsoleOutput.csproj @@ -0,0 +1,17 @@ + + + + Buildvana console output + System.Console-backed IReporter: leveled human-facing console output with activity grouping and child-process passthrough. + $(StandardTfm) + + + + + + + + + + + diff --git a/src/Buildvana.Core.ConsoleOutput/ConsoleReporter.ActivityScope.cs b/src/Buildvana.Core.ConsoleOutput/ConsoleReporter.ActivityScope.cs new file mode 100644 index 0000000..9452809 --- /dev/null +++ b/src/Buildvana.Core.ConsoleOutput/ConsoleReporter.ActivityScope.cs @@ -0,0 +1,51 @@ +// Copyright (C) Tenacom and Contributors. Licensed under the MIT license. +// See the LICENSE file in the project root for full license information. + +using System; +using System.Diagnostics; + +namespace Buildvana.Core.ConsoleOutput; + +public sealed partial class ConsoleReporter +{ + private sealed class ActivityScope : IActivityScope + { + private readonly ConsoleReporter _reporter; + private readonly long _startTimestamp; + private bool _completed; + private bool _disposed; + + public ActivityScope(ConsoleReporter reporter, string title, int depth) + { + _reporter = reporter; + Title = title; + Depth = depth; + _startTimestamp = Stopwatch.GetTimestamp(); + } + + public string Title { get; } + + public int Depth { get; } + + public TimeSpan Elapsed => Stopwatch.GetElapsedTime(_startTimestamp); + + public string? OutcomeMessage { get; private set; } + + public void Complete(string? outcomeMessage = null) + { + _completed = true; + OutcomeMessage = outcomeMessage; + } + + public void Dispose() + { + if (_disposed) + { + return; + } + + _disposed = true; + _reporter.EndActivity(this, _completed); + } + } +} diff --git a/src/Buildvana.Core.ConsoleOutput/ConsoleReporter.cs b/src/Buildvana.Core.ConsoleOutput/ConsoleReporter.cs new file mode 100644 index 0000000..7b2a6be --- /dev/null +++ b/src/Buildvana.Core.ConsoleOutput/ConsoleReporter.cs @@ -0,0 +1,179 @@ +// Copyright (C) Tenacom and Contributors. Licensed under the MIT license. +// See the LICENSE file in the project root for full license information. + +using System; +using System.Collections.Generic; +using System.Globalization; +using System.Threading; +using CommunityToolkit.Diagnostics; + +namespace Buildvana.Core.ConsoleOutput; + +/// +/// An that writes to the process's standard output via . +/// +/// +/// Color is a function of message level, decided here and never by the caller: error: renders in +/// red and warning: in yellow (foreground only, no background fill); the remaining levels are uncolored +/// so they inherit the terminal's theme. The message body is never colored. +/// Output is serialized through an internal lock so that lines streamed from a child process's standard +/// output and standard error (which arrive on background threads) never interleave mid-line with each other or +/// with narration. +/// +public sealed partial class ConsoleReporter : IReporter +{ + private readonly Lock _writeLock = new(); + private readonly Stack _activityStack = new(); + private readonly bool _useColor; + + /// + /// Initializes a new instance of the class. + /// + /// The verbosity that gates which message levels are rendered. + /// + /// to force color on, to force it off, or + /// to auto-detect (color on unless output is redirected or the NO_COLOR + /// environment variable is set). A non- value wins over both, so --color + /// overrides NO_COLOR. + /// + public ConsoleReporter(Verbosity verbosity, bool? colorOverride) + { + Verbosity = verbosity; + _useColor = colorOverride ?? (!IsNoColorSet() && !Console.IsOutputRedirected); + } + + /// + public Verbosity Verbosity { get; } + + /// + public void Report(MessageLevel level, string message) + { + Guard.IsNotNull(message); + if (!this.IsEnabled(level)) + { + return; + } + + lock (_writeLock) + { + WriteLeveledLine(level, message); + } + } + + /// + public IActivityScope BeginActivity(string title) + { + Guard.IsNotNullOrEmpty(title); + lock (_writeLock) + { + var depth = _activityStack.Count + 1; + var scope = new ActivityScope(this, title, depth); + _activityStack.Push(scope); + if (this.IsEnabled(MessageLevel.Info)) + { + Console.WriteLine(FormatActivityLine(depth, title, elapsed: null, outcomeMessage: null)); + } + + return scope; + } + } + + /// + public void ChildOutput(string line, Verbosity? minimumVerbosity) + { + Guard.IsNotNull(line); + + // If a minimum verbosity is specified, respect it; otherwise, write regardless of current verbosity. + // This is useful for child processes whose verbosity we control, e.g., `dotnet`. + if (minimumVerbosity is { } v && !this.IsVerbosityAtLeast(v)) + { + return; + } + + lock (_writeLock) + { + Console.WriteLine(line); + } + } + + /// + public void ChildError(string line, Verbosity? minimumVerbosity) + { + Guard.IsNotNull(line); + + // If a minimum verbosity is specified, respect it; otherwise, write regardless of current verbosity. + // This is useful for child processes whose verbosity we control, e.g., `dotnet`. + if (minimumVerbosity is { } v && !this.IsVerbosityAtLeast(v)) + { + return; + } + + lock (_writeLock) + { + Console.Error.WriteLine(line); + } + } + + /// + /// Determines whether the NO_COLOR environment variable is set. + /// + /// if the NO_COLOR environment variable is set; otherwise, . + /// + /// The NO_COLOR environment variable is a widely-adopted convention for opting out of color in command-line applications. + /// See https://no-color.org for more information. + /// + private static bool IsNoColorSet() => Environment.GetEnvironmentVariable("NO_COLOR") is { Length: > 0 }; + + private static (ConsoleColor? Color, string Word) StyleFor(MessageLevel level) => level switch + { + MessageLevel.Error => (ConsoleColor.Red, "error"), + MessageLevel.Warning => (ConsoleColor.Yellow, "warning"), + MessageLevel.Info => (null, "info"), + MessageLevel.Detail => (null, "detail"), + MessageLevel.Trace => (null, "trace"), + _ => ThrowHelper.ThrowArgumentOutOfRangeException<(ConsoleColor?, string)>(nameof(level), level, "Unknown message level."), + }; + + // Activity header/outcome lines are label-less; the leading "[depth]" conveys nesting without indentation. + private static string FormatActivityLine(int depth, string title, TimeSpan? elapsed, string? outcomeMessage) + => elapsed is { } e + ? string.Format(CultureInfo.InvariantCulture, "[{0}] {1}: done ({2:F1}s){3}{4}", depth, title, e.TotalSeconds, outcomeMessage is null ? string.Empty : " - ", outcomeMessage) + : string.Format(CultureInfo.InvariantCulture, "[{0}] {1}: starting...", depth, title); + + private void WriteLeveledLine(MessageLevel level, string message) + { + var (color, word) = StyleFor(level); + if (_useColor && color is { } foreground) + { + Console.ForegroundColor = foreground; + Console.Write(word); + Console.Write(':'); + Console.ResetColor(); + } + else + { + Console.Write(word); + Console.Write(':'); + } + + Console.Write(' '); + Console.WriteLine(message); + } + + private void EndActivity(ActivityScope scope, bool completed) + { + lock (_writeLock) + { + if (_activityStack.Count > 0 && ReferenceEquals(_activityStack.Peek(), scope)) + { + _activityStack.Pop(); + } + + // No outcome line unless the activity was explicitly completed (e.g. the work threw before Complete). + if (completed && this.IsEnabled(MessageLevel.Info)) + { + Console.WriteLine(FormatActivityLine(scope.Depth, scope.Title, scope.Elapsed, scope.OutcomeMessage)); + } + } + } +} diff --git a/src/Buildvana.Core.Process/ProcessRunner.cs b/src/Buildvana.Core.Process/ProcessRunner.cs index 9ff3cc9..5ad080a 100644 --- a/src/Buildvana.Core.Process/ProcessRunner.cs +++ b/src/Buildvana.Core.Process/ProcessRunner.cs @@ -28,6 +28,7 @@ public async Task RunAsync( string? workingDirectory = null, bool throwOnNonZero = true, Action? onStdout = null, + Action? onStderr = null, CancellationToken cancellationToken = default) { Guard.IsNotNullOrEmpty(executable); @@ -50,7 +51,15 @@ public async Task RunAsync( PipeTarget.ToStringBuilder(stdoutBuffer), PipeTarget.ToDelegate(onStdout)); - var stderrPipe = PipeTarget.Merge(stderrCapture, PipeTarget.ToStringBuilder(stderrBuffer)); + // When the caller wants line-by-line stderr, fan the stream out to their callback too. + var stderrPipe = onStderr is null + ? PipeTarget.Merge( + stderrCapture, + PipeTarget.ToStringBuilder(stderrBuffer)) + : PipeTarget.Merge( + stderrCapture, + PipeTarget.ToStringBuilder(stderrBuffer), + PipeTarget.ToDelegate(onStderr)); var command = Cli.Wrap(executable) diff --git a/src/Buildvana.Sdk.Tasks/Buildvana.Sdk.Tasks.csproj b/src/Buildvana.Sdk.Tasks/Buildvana.Sdk.Tasks.csproj index a544228..a539a26 100644 --- a/src/Buildvana.Sdk.Tasks/Buildvana.Sdk.Tasks.csproj +++ b/src/Buildvana.Sdk.Tasks/Buildvana.Sdk.Tasks.csproj @@ -25,6 +25,7 @@ + diff --git a/src/Buildvana.Tool/Build/BuildPipeline.cs b/src/Buildvana.Tool/Build/BuildPipeline.cs index 341263f..e408c27 100644 --- a/src/Buildvana.Tool/Build/BuildPipeline.cs +++ b/src/Buildvana.Tool/Build/BuildPipeline.cs @@ -5,13 +5,13 @@ using System.IO; using System.Threading; using System.Threading.Tasks; +using Buildvana.Core.ConsoleOutput; using Buildvana.Tool.CommandLine; using Buildvana.Tool.Infrastructure; using Buildvana.Tool.Services; using Buildvana.Tool.Services.Solution; using Buildvana.Tool.Utilities; using CommunityToolkit.Diagnostics; -using Microsoft.Extensions.Logging; namespace Buildvana.Tool.Build; @@ -30,7 +30,7 @@ internal sealed class BuildPipeline private readonly SolutionContext _solution; private readonly DotNetService _dotnet; - private readonly ILoggerFactory _loggerFactory; + private readonly IReporter _reporter; private readonly IReadOnlyList _forwardedArgs; /// @@ -39,16 +39,16 @@ internal sealed class BuildPipeline public BuildPipeline( SolutionContext solution, DotNetService dotnet, - ILoggerFactory loggerFactory, + IReporter reporter, CommandParameters parameters) { Guard.IsNotNull(solution); Guard.IsNotNull(dotnet); - Guard.IsNotNull(loggerFactory); + Guard.IsNotNull(reporter); Guard.IsNotNull(parameters); _solution = solution; _dotnet = dotnet; - _loggerFactory = loggerFactory; + _reporter = reporter; _forwardedArgs = parameters.Forwarded; } @@ -87,8 +87,10 @@ public async Task RunRangeAsync(BuildStep first, BuildStep last, string configur /// The MSBuild configuration to build (ignored by and ). /// A token to observe while running the step. When signalled, the running dotnet child process is terminated. /// A representing the ongoing operation. - public Task RunAsync(BuildStep step, string configuration = DefaultConfiguration, CancellationToken cancellationToken = default) - => step switch + public async Task RunAsync(BuildStep step, string configuration = DefaultConfiguration, CancellationToken cancellationToken = default) + { + using var activity = _reporter.BeginActivity(step.ToString()); + var task = step switch { BuildStep.Clean => CleanAsync(cancellationToken), BuildStep.Restore => RestoreAsync(cancellationToken), @@ -97,21 +99,23 @@ public Task RunAsync(BuildStep step, string configuration = DefaultConfiguration BuildStep.Pack => PackAsync(configuration, cancellationToken), _ => ThrowHelper.ThrowArgumentOutOfRangeException(nameof(step), step, "Unknown build step."), }; + await task.ConfigureAwait(false); + activity.Complete(); + } private Task CleanAsync(CancellationToken cancellationToken) { - var logger = _loggerFactory.CreateLogger("Clean"); - FileSystemHelper.DeleteDirectory(_solution.ResolvePath(".vs"), logger); - FileSystemHelper.DeleteDirectory(_solution.ResolvePath("_ReSharper.Caches"), logger); - FileSystemHelper.DeleteDirectory(_solution.ResolvePath("temp"), logger); - FileSystemHelper.DeleteDirectory(_solution.ResolvePath(CommonPaths.AllArtifacts), logger); - FileSystemHelper.DeleteDirectory(_solution.ResolvePath(CommonPaths.TestResults), logger); + FileSystemHelper.DeleteDirectory(_solution.ResolvePath(".vs"), _reporter); + FileSystemHelper.DeleteDirectory(_solution.ResolvePath("_ReSharper.Caches"), _reporter); + FileSystemHelper.DeleteDirectory(_solution.ResolvePath("temp"), _reporter); + FileSystemHelper.DeleteDirectory(_solution.ResolvePath(CommonPaths.AllArtifacts), _reporter); + FileSystemHelper.DeleteDirectory(_solution.ResolvePath(CommonPaths.TestResults), _reporter); foreach (var project in _solution.Model.SolutionProjects) { cancellationToken.ThrowIfCancellationRequested(); var projectDirectory = Path.GetDirectoryName(_solution.ResolveProjectPath(project))!; - FileSystemHelper.DeleteDirectory(Path.Combine(projectDirectory, "bin"), logger); - FileSystemHelper.DeleteDirectory(Path.Combine(projectDirectory, "obj"), logger); + FileSystemHelper.DeleteDirectory(Path.Combine(projectDirectory, "bin"), _reporter); + FileSystemHelper.DeleteDirectory(Path.Combine(projectDirectory, "obj"), _reporter); } return Task.CompletedTask; diff --git a/src/Buildvana.Tool/Buildvana.Tool.csproj b/src/Buildvana.Tool/Buildvana.Tool.csproj index 104ea37..33cf7a9 100644 --- a/src/Buildvana.Tool/Buildvana.Tool.csproj +++ b/src/Buildvana.Tool/Buildvana.Tool.csproj @@ -25,6 +25,7 @@ + @@ -40,8 +41,6 @@ - - diff --git a/src/Buildvana.Tool/Infrastructure/DependencyInjection/LazyResolver`1.cs b/src/Buildvana.Tool/Infrastructure/DependencyInjection/LazyResolver`1.cs new file mode 100644 index 0000000..a4e4b83 --- /dev/null +++ b/src/Buildvana.Tool/Infrastructure/DependencyInjection/LazyResolver`1.cs @@ -0,0 +1,17 @@ +// Copyright (C) Tenacom and Contributors. Licensed under the MIT license. +// See the LICENSE file in the project root for full license information. + +using System; +using System.Threading; +using Microsoft.Extensions.DependencyInjection; + +namespace Buildvana.Tool.Infrastructure.DependencyInjection; + +internal sealed class LazyResolver : Lazy + where T : notnull +{ + public LazyResolver(IServiceProvider provider) + : base(provider.GetRequiredService, LazyThreadSafetyMode.ExecutionAndPublication) + { + } +} diff --git a/src/Buildvana.Tool/Infrastructure/DependencyInjection/ServiceCollectionExtensions.cs b/src/Buildvana.Tool/Infrastructure/DependencyInjection/ServiceCollectionExtensions.cs new file mode 100644 index 0000000..1e7a5b0 --- /dev/null +++ b/src/Buildvana.Tool/Infrastructure/DependencyInjection/ServiceCollectionExtensions.cs @@ -0,0 +1,22 @@ +// Copyright (C) Tenacom and Contributors. Licensed under the MIT license. +// See the LICENSE file in the project root for full license information. + +using System; +using Microsoft.Extensions.DependencyInjection; + +namespace Buildvana.Tool.Infrastructure.DependencyInjection; + +/// +/// Provides extension methods for to add Buildvana Tool services. +/// +internal static class ServiceCollectionExtensions +{ + extension(IServiceCollection @this) + { + /// + /// Adds support for resolving instances. + /// + /// The service collection for chaining. + public IServiceCollection AddLazySupport() => @this.AddTransient(typeof(Lazy<>), typeof(LazyResolver<>)); + } +} diff --git a/src/Buildvana.Tool/Infrastructure/Logging/SpectreLogger.cs b/src/Buildvana.Tool/Infrastructure/Logging/SpectreLogger.cs deleted file mode 100644 index c0df901..0000000 --- a/src/Buildvana.Tool/Infrastructure/Logging/SpectreLogger.cs +++ /dev/null @@ -1,68 +0,0 @@ -// Copyright (C) Tenacom and Contributors. Licensed under the MIT license. -// See the LICENSE file in the project root for full license information. - -using System; -using CommunityToolkit.Diagnostics; -using Microsoft.Extensions.Logging; -using Spectre.Console; - -namespace Buildvana.Tool.Infrastructure.Logging; - -internal sealed class SpectreLogger : ILogger -{ - private readonly IAnsiConsole _console; - private readonly string _categoryName; - private readonly SpectreLoggerProvider _provider; - - public SpectreLogger(IAnsiConsole console, string categoryName, SpectreLoggerProvider provider) - { - _console = console; - _categoryName = categoryName; - _provider = provider; - } - - public IDisposable? BeginScope(TState state) - where TState : notnull - => null; - - public bool IsEnabled(LogLevel logLevel) => logLevel != LogLevel.None && logLevel >= _provider.MinLevel; - - public void Log( - LogLevel logLevel, - EventId eventId, - TState state, - Exception? exception, - Func formatter) - { - Guard.IsNotNull(formatter); - if (!IsEnabled(logLevel)) - { - return; - } - - var message = formatter(state, exception); - if (string.IsNullOrEmpty(message) && exception is null) - { - return; - } - - var (style, label) = logLevel switch { - LogLevel.Trace => ("grey", "trce"), - LogLevel.Debug => ("grey", "dbug"), - LogLevel.Information => ("white", "info"), - LogLevel.Warning => ("yellow", "warn"), - LogLevel.Error => ("red", "fail"), - LogLevel.Critical => ("red bold", "crit"), - _ => ("white", "????"), - }; - - // Markup.Escape: log messages may contain '[' or ']' which Spectre would otherwise interpret as markup. - _console.MarkupLine( - $"[{style}]{label}[/]: [silver]{Markup.Escape(_categoryName)}[/]: {Markup.Escape(message)}"); - - if (exception is not null) - { - _console.WriteException(exception); - } - } -} diff --git a/src/Buildvana.Tool/Infrastructure/Logging/SpectreLoggerProvider.cs b/src/Buildvana.Tool/Infrastructure/Logging/SpectreLoggerProvider.cs deleted file mode 100644 index d6b7cb8..0000000 --- a/src/Buildvana.Tool/Infrastructure/Logging/SpectreLoggerProvider.cs +++ /dev/null @@ -1,53 +0,0 @@ -// Copyright (C) Tenacom and Contributors. Licensed under the MIT license. -// See the LICENSE file in the project root for full license information. - -using System; -using System.Collections.Concurrent; -using CommunityToolkit.Diagnostics; -using Microsoft.Extensions.Logging; -using Spectre.Console; - -namespace Buildvana.Tool.Infrastructure.Logging; - -/// -/// An that writes log entries through an . -/// -/// -/// TTY awareness is delegated to the supplied : when output is redirected, -/// Spectre.Console's default profile downgrades to plain ASCII automatically, so this provider needs no -/// extra detection logic of its own. -/// The provider does not own the supplied console; is a no-op. -/// -internal sealed class SpectreLoggerProvider : ILoggerProvider -{ - private readonly IAnsiConsole _console; - private readonly ConcurrentDictionary _loggers = new(StringComparer.Ordinal); - - /// - /// Initializes a new instance of the class. - /// - /// The console to which log entries are written. - public SpectreLoggerProvider(IAnsiConsole console) - { - Guard.IsNotNull(console); - _console = console; - } - - /// - /// Gets or sets the minimum level emitted by loggers from this provider. Mutable so the entry command - /// can apply --verbosity after parsing settings. - /// - public LogLevel MinLevel { get; set; } = LogLevel.Information; - - /// - public ILogger CreateLogger(string categoryName) - { - Guard.IsNotNull(categoryName); - return _loggers.GetOrAdd(categoryName, name => new SpectreLogger(_console, name, this)); - } - - /// - public void Dispose() - { - } -} diff --git a/src/Buildvana.Tool/Program.cs b/src/Buildvana.Tool/Program.cs index 88a3e29..2c10505 100644 --- a/src/Buildvana.Tool/Program.cs +++ b/src/Buildvana.Tool/Program.cs @@ -6,14 +6,15 @@ using System.Threading.Tasks; using Buildvana.Core; using Buildvana.Core.Configuration; +using Buildvana.Core.ConsoleOutput; using Buildvana.Core.HomeDirectory; using Buildvana.Core.Json; using Buildvana.Core.Process; using Buildvana.Tool.Build; using Buildvana.Tool.CommandLine; using Buildvana.Tool.Configuration; +using Buildvana.Tool.Infrastructure.DependencyInjection; using Buildvana.Tool.Infrastructure.Execution; -using Buildvana.Tool.Infrastructure.Logging; using Buildvana.Tool.Services; using Buildvana.Tool.Services.Git; using Buildvana.Tool.Services.PublicApiFiles; @@ -22,7 +23,6 @@ using Buildvana.Tool.Services.Versioning; using Buildvana.Tool.Subcommands; using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Logging; using Spectre.Console; namespace Buildvana.Tool; @@ -36,6 +36,10 @@ public static async Task Main(string[] args) { var console = AnsiConsole.Console; + // Assigned once --verbosity and --color/--no-color are known. The outer catch falls back to a default + // reporter for errors that occur before that point (e.g. an invalid --verbosity value). + IReporter? reporter = null; + try { var parsed = CliArgSplitter.Split(args); @@ -77,11 +81,19 @@ public static async Task Main(string[] args) CommandArgumentValidator.Validate(command, parsed); - // Parse --verbosity eagerly (so the error surfaces in the outer catch) but defer SpectreLoggerProvider - // construction to the DI factory below, so the container owns disposal. - var initialLogLevel = globals.Verbosity is null ? LogLevel.Information : ParseVerbosity(globals.Verbosity); + // Parse --verbosity eagerly so an invalid value surfaces in the outer catch. + var verbosity = globals.Verbosity is null ? Verbosity.Normal : ParseVerbosity(globals.Verbosity); + + // --color / --no-color win over auto-detection; neither (or both) leaves the reporter to auto-detect. + bool? colorOverride = (globals.Color, globals.NoColor) switch + { + (true, false) => true, + (false, true) => false, + _ => null, + }; + reporter = new ConsoleReporter(verbosity, colorOverride); - var services = BuildServiceProvider(console, globals, parsed, initialLogLevel); + var services = BuildServiceProvider(reporter, globals, parsed); await using (services.ConfigureAwait(false)) { var cts = new CancellationTokenSource(); @@ -130,30 +142,29 @@ void OnCancel(object? sender, ConsoleCancelEventArgs e) } catch (OperationCanceledException) { - console.MarkupLine("[yellow]Operation cancelled.[/]"); + (reporter ?? CreateDefaultReporter()).Error("Operation cancelled."); return CancelledExitCode; } catch (BuildFailedException ex) { - console.MarkupLineInterpolated($"[red]{ex.Message}[/]"); + (reporter ?? CreateDefaultReporter()).Error(ex.Message); return ex.ExitCode; } + + static IReporter CreateDefaultReporter() => new ConsoleReporter(Verbosity.Normal, colorOverride: null); } private static ServiceProvider BuildServiceProvider( - IAnsiConsole console, + IReporter reporter, GlobalSettings globals, - ParsedCommandLine parsed, - LogLevel initialLogLevel) + ParsedCommandLine parsed) { var services = new ServiceCollection() - .AddSingleton(console) - .AddSingleton(_ => new SpectreLoggerProvider(console) { MinLevel = initialLogLevel }) - .AddSingleton(static sp => sp.GetRequiredService()) + .AddLazySupport() + .AddSingleton(reporter) .AddSingleton(globals) .AddSingleton(new CommandParameters(parsed.OptionTokens, parsed.Forwarded)) .AddSingleton(static sp => ReleaseSettings.Parse(sp.GetRequiredService().Options)) - .AddLogging(static builder => builder.SetMinimumLevel(LogLevel.Trace)) .AddSingleton(static _ => new DiscoveredHomeDirectoryProvider(Environment.CurrentDirectory)) // Lazy by design: this factory (and thus discovery, parsing, and validation) runs on first resolve. @@ -184,13 +195,13 @@ private static ServiceProvider BuildServiceProvider( return services.BuildServiceProvider(); } - private static LogLevel ParseVerbosity(string raw) => raw.ToUpperInvariant() switch + private static Verbosity ParseVerbosity(string raw) => raw.ToUpperInvariant() switch { - "QUIET" or "Q" => LogLevel.Error, - "MINIMAL" or "M" => LogLevel.Warning, - "NORMAL" or "N" => LogLevel.Information, - "DETAILED" or "D" => LogLevel.Debug, - "DIAGNOSTIC" or "DIAG" => LogLevel.Trace, + "QUIET" or "Q" => Verbosity.Quiet, + "MINIMAL" or "M" => Verbosity.Minimal, + "NORMAL" or "N" => Verbosity.Normal, + "DETAILED" or "D" => Verbosity.Detailed, + "DIAGNOSTIC" or "DIAG" => Verbosity.Diagnostic, _ => throw new BuildFailedException($"Unknown verbosity level '{raw}'. Use one of: quiet, minimal, normal, detailed, diagnostic."), }; } diff --git a/src/Buildvana.Tool/Services/ChangelogService.cs b/src/Buildvana.Tool/Services/ChangelogService.cs index 3483987..7a709a5 100644 --- a/src/Buildvana.Tool/Services/ChangelogService.cs +++ b/src/Buildvana.Tool/Services/ChangelogService.cs @@ -9,10 +9,10 @@ using System.Text; using System.Text.RegularExpressions; using Buildvana.Core; +using Buildvana.Core.ConsoleOutput; using Buildvana.Tool.Services.ServerAdapters; using Buildvana.Tool.Services.Versioning; using CommunityToolkit.Diagnostics; -using Microsoft.Extensions.Logging; namespace Buildvana.Tool.Services; @@ -26,19 +26,19 @@ internal sealed partial class ChangelogService /// public const string FileName = "CHANGELOG.md"; - private readonly ILogger _logger; + private readonly IReporter _reporter; private readonly ServerAdapter _server; private readonly VersionService _version; /// /// Initializes a new instance of the class. /// - public ChangelogService(ILogger logger, ServerAdapter server, VersionService version) + public ChangelogService(IReporter reporter, ServerAdapter server, VersionService version) { - Guard.IsNotNull(logger); + Guard.IsNotNull(reporter); Guard.IsNotNull(server); Guard.IsNotNull(version); - _logger = logger; + _reporter = reporter; _server = server; _version = version; Exists = File.Exists(FileName); @@ -94,7 +94,7 @@ public bool HasUnreleasedChanges() /// public void PrepareForRelease() { - _logger.LogInformation("Updating changelog..."); + _reporter.Info("Updating changelog..."); var encoding = new UTF8Encoding(false, true); var sb = new StringBuilder(); using (var reader = new StreamReader(FileName, encoding)) @@ -224,7 +224,7 @@ List CollectNewSectionLines() /// public void UpdateNewSectionTitle() { - _logger.LogInformation("Updating changelog's new release section title..."); + _reporter.Info("Updating changelog's new release section title..."); var encoding = new UTF8Encoding(false, true); var sb = new StringBuilder(); using (var reader = new StreamReader(FileName, encoding)) diff --git a/src/Buildvana.Tool/Services/DocFxService.cs b/src/Buildvana.Tool/Services/DocFxService.cs index 431f70b..7335bcc 100644 --- a/src/Buildvana.Tool/Services/DocFxService.cs +++ b/src/Buildvana.Tool/Services/DocFxService.cs @@ -4,11 +4,11 @@ using System.IO; using System.Text.Json; using System.Threading.Tasks; +using Buildvana.Core.ConsoleOutput; using Buildvana.Tool.Infrastructure; using Buildvana.Tool.Services.ServerAdapters; using Buildvana.Tool.Services.Versioning; using CommunityToolkit.Diagnostics; -using Microsoft.Extensions.Logging; namespace Buildvana.Tool.Services; @@ -27,11 +27,11 @@ internal sealed class DocFxService /// Initializes a new instance of the class. /// public DocFxService( - ILogger logger, + IReporter reporter, ServerAdapter server, VersionService version) { - Guard.IsNotNull(logger); + Guard.IsNotNull(reporter); Guard.IsNotNull(server); Guard.IsNotNull(version); @@ -42,7 +42,7 @@ public DocFxService( IsEnabled = File.Exists(_configPath); if (!IsEnabled) { - logger.LogInformation("{ConfigPath} not found: DocFX operations will be skipped.", _configPath); + reporter.Info($"{_configPath} not found: DocFX operations will be skipped."); } } diff --git a/src/Buildvana.Tool/Services/DotNetService.InvocationKind.cs b/src/Buildvana.Tool/Services/DotNetService.InvocationKind.cs new file mode 100644 index 0000000..b72f106 --- /dev/null +++ b/src/Buildvana.Tool/Services/DotNetService.InvocationKind.cs @@ -0,0 +1,25 @@ +// Copyright (C) Tenacom and Contributors. Licensed under the MIT license. +// See the LICENSE file in the project root for full license information. + +namespace Buildvana.Tool.Services; + +partial class DotNetService +{ + private enum InvocationKind + { + /// + /// A normal invocation: `dotnet` accepts the `--verbosity` argument and the user is interested in the output. + /// + Normal, + + /// + /// An informational invocation: the user is interested in the output, but `dotnet` does not accept the `--verbosity` argument. + /// + Informational, + + /// + /// An internal invocation: the user is not interested in the output, and the `--verbosity` argument, if any, is already set appropriately. + /// + Internal, + } +} diff --git a/src/Buildvana.Tool/Services/DotNetService.cs b/src/Buildvana.Tool/Services/DotNetService.cs index 9cd4b53..86e6d76 100644 --- a/src/Buildvana.Tool/Services/DotNetService.cs +++ b/src/Buildvana.Tool/Services/DotNetService.cs @@ -3,19 +3,18 @@ using System; using System.Collections.Generic; +using System.Diagnostics; using System.Linq; using System.Threading; using System.Threading.Tasks; +using Buildvana.Core.ConsoleOutput; using Buildvana.Tool.Configuration; using Buildvana.Tool.Infrastructure; using Buildvana.Tool.Services.ServerAdapters; using Buildvana.Tool.Services.Solution; using Buildvana.Tool.Services.Versioning; -using Buildvana.Tool.Subcommands; using Buildvana.Tool.Utilities; using CommunityToolkit.Diagnostics; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Logging; using IProcessRunner = Buildvana.Core.Process.IProcessRunner; using ProcessResult = Buildvana.Core.Process.ProcessResult; @@ -25,7 +24,7 @@ namespace Buildvana.Tool.Services; /// /// Provides shortcut methods for .NET SDK operations. /// -internal sealed class DotNetService +internal sealed partial class DotNetService { // The muxer sets DOTNET_HOST_PATH to the full path of the dotnet executable that launched us, // so we re-invoke that exact host instead of relying on `dotnet` being on PATH. @@ -34,42 +33,34 @@ private static readonly string DotNetMuxer ? p : "dotnet"; - private readonly ILogger _logger; + private readonly IReporter _reporter; private readonly IProcessRunner _processRunner; - private readonly IServiceProvider _services; + private readonly Lazy _nugetPushConfigurationLazy; private readonly ServerAdapter _server; private readonly VersionService _version; - private readonly GlobalSettings _globals; /// /// Initializes a new instance of the class. /// public DotNetService( - ILogger logger, + IReporter reporter, IProcessRunner processRunner, - IServiceProvider services, + Lazy nugetPushConfigurationLazy, ServerAdapter server, - VersionService version, - GlobalSettings globals) + VersionService version) { - Guard.IsNotNull(logger); + Guard.IsNotNull(reporter); Guard.IsNotNull(processRunner); - Guard.IsNotNull(services); + Guard.IsNotNull(nugetPushConfigurationLazy); Guard.IsNotNull(server); Guard.IsNotNull(version); - Guard.IsNotNull(globals); - _logger = logger; + _reporter = reporter; _processRunner = processRunner; - _services = services; + _nugetPushConfigurationLazy = nugetPushConfigurationLazy; _server = server; _version = version; - _globals = globals; } - // The verbosity bv forwards to every `dotnet` invocation. bv and the .NET CLI share the same vocabulary - // (quiet/minimal/normal/detailed/diagnostic and their short forms), so the raw value is forwarded as-is. - private string Verbosity => _globals.Verbosity ?? "normal"; - /// /// Asynchronously restores all NuGet packages for the solution. /// @@ -81,11 +72,17 @@ public Task RestoreSolutionAsync(SolutionContext solution, IReadOnlyList { Guard.IsNotNull(solution); Guard.IsNotNull(forwardedArgs); - _logger.LogInformation("Restoring NuGet packages for solution..."); - List args = ["restore", solution.SolutionPath, "--disable-parallel", "-nologo", "-v", Verbosity]; - args.AddRange(forwardedArgs); - args.Add(ContinuousIntegrationBuildArg(dotnetTest: false)); - return RunDotNetAsync(args, cancellationToken); + _reporter.Info("Restoring NuGet packages for solution..."); + string[] args = [ + "restore", + solution.SolutionPath, + "--disable-parallel", + "-nologo", + ..forwardedArgs, + ContinuousIntegrationBuildArg(asMSBuildPassthrough: true), + ]; + + return RunDotNetAsync(args, InvocationKind.Normal, cancellationToken: cancellationToken); } /// @@ -102,16 +99,18 @@ public Task BuildSolutionAsync(SolutionContext solution, string configuration, I Guard.IsNotNull(solution); Guard.IsNotNullOrEmpty(configuration); Guard.IsNotNull(forwardedArgs); - _logger.LogInformation("Building solution (restore = {Restore})...", restore); - List args = ["build", solution.SolutionPath, "-nologo", "-v", Verbosity, $"-p:Configuration={configuration}"]; - if (!restore) - { - args.Add("--no-restore"); - } + _reporter.Info($"Building solution (restore = {restore})..."); + string[] args = [ + "build", + solution.SolutionPath, + "-nologo", + $"-p:Configuration={configuration}", + .. restore ? Array.Empty() : ["--no-restore"], + ..forwardedArgs, + ContinuousIntegrationBuildArg(asMSBuildPassthrough: true), + ]; - args.AddRange(forwardedArgs); - args.Add(ContinuousIntegrationBuildArg(dotnetTest: false)); - return RunDotNetAsync(args, cancellationToken); + return RunDotNetAsync(args, InvocationKind.Normal, cancellationToken: cancellationToken); } /// @@ -130,21 +129,21 @@ public async Task TestSolutionAsync(SolutionContext solution, string configurati Guard.IsNotNull(solution); Guard.IsNotNullOrEmpty(configuration); Guard.IsNotNull(forwardedArgs); - _logger.LogInformation("Checking for MTP test projects..."); + _reporter.Info("Checking for MTP test projects..."); var hasTestProjects = false; foreach (var project in solution.Model.SolutionProjects) { var projectPath = solution.ResolveProjectPath(project); - _logger.LogDebug("Checking '{Path}'...", projectPath); + _reporter.Detail($"Checking '{projectPath}'..."); // bv-internal MSBuild evaluation: do not forward the user's arguments here, as they may be // test-application options that `dotnet msbuild` would reject. - List probeArgs = ["msbuild", projectPath, "-nologo", "-getProperty:IsTestingPlatformApplication"]; - var probe = await RunDotNetAsync(probeArgs, cancellationToken).ConfigureAwait(false); + string[] probeArgs = ["msbuild", projectPath, "-nologo", "-getProperty:IsTestingPlatformApplication"]; + var probe = await RunDotNetAsync(probeArgs, InvocationKind.Internal, cancellationToken: cancellationToken).ConfigureAwait(false); if (string.Equals(probe.StandardOutput.Trim(), "true", StringComparison.OrdinalIgnoreCase)) { - _logger.LogDebug("Project '{Path}' is a test project, will run tests.", projectPath); + _reporter.Detail($"Project '{projectPath}' is a test project, will run tests."); hasTestProjects = true; break; } @@ -152,30 +151,30 @@ public async Task TestSolutionAsync(SolutionContext solution, string configurati if (!hasTestProjects) { - _logger.LogInformation("No test projects found, skipping tests."); + _reporter.Info("No test projects found, skipping tests."); return; } - _logger.LogInformation("Running tests (restore = {Restore}, build = {Build})...", restore, build); + _reporter.Info($"Running tests (restore = {restore}, build = {build})..."); // `dotnet test` consumes --verbosity itself; the configuration and ContinuousIntegrationBuild are // passed as MSBuild properties using the `--property:` form, which is what `dotnet test` understands // (the `-p:` form is not supported here). - List args = ["test", solution.SolutionPath, $"--verbosity={Verbosity}", $"--property:Configuration={configuration}"]; - if (!build) - { - args.Add("--no-build"); - } + string[] args = [ + "test", + solution.SolutionPath, + $"--property:Configuration={configuration}", + .. restore ? Array.Empty() : ["--no-restore"], + .. build ? Array.Empty() : ["--no-build"], + "--results-directory", + CommonPaths.TestResults, + "--output", + _reporter.IsVerbosityAtLeast(Verbosity.Detailed) ? "Detailed" : "Normal", + ..forwardedArgs, + ContinuousIntegrationBuildArg(asMSBuildPassthrough: false), + ]; - if (!restore) - { - args.Add("--no-restore"); - } - - args.AddRange(["--coverage", "--coverage-output-format", "cobertura", "--results-directory", CommonPaths.TestResults]); - args.AddRange(forwardedArgs); - args.Add(ContinuousIntegrationBuildArg(dotnetTest: true)); - await RunDotNetAsync(args, cancellationToken).ConfigureAwait(false); + await RunDotNetAsync(args, InvocationKind.Normal, cancellationToken: cancellationToken).ConfigureAwait(false); } /// @@ -193,21 +192,19 @@ public Task PackSolutionAsync(SolutionContext solution, string configuration, IR Guard.IsNotNull(solution); Guard.IsNotNullOrEmpty(configuration); Guard.IsNotNull(forwardedArgs); - _logger.LogInformation("Packing solution (restore = {Restore}, build = {Build})...", restore, build); - List args = ["pack", solution.SolutionPath, "-nologo", "-v", Verbosity, $"-p:Configuration={configuration}"]; - if (!build) - { - args.Add("--no-build"); - } + _reporter.Info($"Packing solution (restore = {restore}, build = {build})..."); + string[] args = [ + "pack", + solution.SolutionPath, + "-nologo", + $"-p:Configuration={configuration}", + .. restore ? Array.Empty() : ["--no-restore"], + .. build ? Array.Empty() : ["--no-build"], + ..forwardedArgs, + ContinuousIntegrationBuildArg(asMSBuildPassthrough: true), + ]; - if (!restore) - { - args.Add("--no-restore"); - } - - args.AddRange(forwardedArgs); - args.Add(ContinuousIntegrationBuildArg(dotnetTest: false)); - return RunDotNetAsync(args, cancellationToken); + return RunDotNetAsync(args, InvocationKind.Normal, cancellationToken: cancellationToken); } /// @@ -222,35 +219,70 @@ public async Task NuGetPushAllAsync(string artifactsPath, CancellationToken canc var packages = FileSystemHelper.EnumerateFiles(artifactsPath, "*.nupkg").ToArray(); if (packages.Length == 0) { - _logger.LogDebug("No .nupkg files to push."); + _reporter.Detail("No .nupkg files to push."); return; } var isPrivate = await _server.IsPrivateRepositoryAsync().ConfigureAwait(false); - var nugetConfig = _services.GetRequiredService(); + var nugetConfig = _nugetPushConfigurationLazy.Value; var target = isPrivate ? nugetConfig.Private : _version.IsPrerelease ? nugetConfig.Prerelease : nugetConfig.Release; foreach (var path in packages) { - _logger.LogInformation("Pushing {Path} to {Source}...", path, target.Source); - await _processRunner - .RunAsync( - DotNetMuxer, - ["nuget", "push", path, "--source", target.Source, "--api-key", target.ApiKey, "--skip-duplicate", "--force-english-output"], - cancellationToken: cancellationToken) - .ConfigureAwait(false); + _reporter.Detail($"Pushing {path} to {target.Source}..."); + string[] args = [ + "nuget", + "push", + path, + "--source", + target.Source, + "--api-key", + target.ApiKey, + "--skip-duplicate", + ]; + await _processRunner.RunAsync(DotNetMuxer, args, cancellationToken: cancellationToken).ConfigureAwait(false); } + + _reporter.Info($"Pushed {packages.Length} packages to {target.Source}."); } - // bv's authoritative ContinuousIntegrationBuild value, emitted in the trailing group so it wins under - // MSBuild's last-wins resolution. `dotnet test` requires the `--property:` form; the other verbs accept `-p:`. - private string ContinuousIntegrationBuildArg(bool dotnetTest) + /// + /// Return a parameter string that reflects whether we're running in CI. + /// + /// to use the MSBuild passthrough form (-p:), + /// to use the standard form (--property:). + /// A string representing the ContinuousIntegrationBuild property setting parameter for the dotnet command. + private string ContinuousIntegrationBuildArg(bool asMSBuildPassthrough) { - var prefix = dotnetTest ? "--property:" : "-p:"; + var prefix = asMSBuildPassthrough ? "-p:" : "--property:"; return $"{prefix}ContinuousIntegrationBuild={(_server.IsCloudBuild ? "true" : "false")}"; } - private Task RunDotNetAsync(IEnumerable args, CancellationToken cancellationToken = default) - => _processRunner.RunAsync(DotNetMuxer, args, cancellationToken: cancellationToken); + /// + /// Runs a dotnet command with the specified arguments, forwarding the output to the reporter according to the current verbosity. + /// + /// The arguments to pass to dotnet, excluding the verbosity argument, which is automatically appended according to the current verbosity. + /// The kind of invocation, which determines how `dotnet` verbosity and output streaming are handled. + /// A token that, when signalled, terminates the spawned dotnet child process. + /// A representing the ongoing operation, with a result describing child process outcome. + private Task RunDotNetAsync( + IEnumerable args, + InvocationKind invocationKind, + CancellationToken cancellationToken = default) + { + var (appendVerbosity, streamOutput) = invocationKind switch { + InvocationKind.Normal => (AppendVerbosity: true, StreamOutput: true), + InvocationKind.Informational => (AppendVerbosity: false, StreamOutput: true), + InvocationKind.Internal => (AppendVerbosity: false, StreamOutput: false), + _ => throw new UnreachableException(), + }; + + return _processRunner.RunAsync( + DotNetMuxer, + appendVerbosity ? args.Append($"--verbosity={_reporter.Verbosity}") : args, + onStdout: streamOutput ? (x) => _reporter.ChildOutput(x, null) : null, + onStderr: streamOutput ? (x) => _reporter.ChildError(x, null) : null, + cancellationToken: cancellationToken); + } } diff --git a/src/Buildvana.Tool/Services/Git/GitService.cs b/src/Buildvana.Tool/Services/Git/GitService.cs index 9b110b6..55d0844 100644 --- a/src/Buildvana.Tool/Services/Git/GitService.cs +++ b/src/Buildvana.Tool/Services/Git/GitService.cs @@ -7,11 +7,11 @@ using System.IO; using System.Linq; using Buildvana.Core; +using Buildvana.Core.ConsoleOutput; using Buildvana.Core.HomeDirectory; using CommunityToolkit.Diagnostics; using JetBrains.Annotations; using LibGit2Sharp; -using Microsoft.Extensions.Logging; using NuGet.Versioning; using GlobalSettings = Buildvana.Tool.Subcommands.GlobalSettings; @@ -23,16 +23,16 @@ namespace Buildvana.Tool.Services.Git; [PublicAPI] internal sealed class GitService : IDisposable { - private readonly ILogger _logger; + private readonly IReporter _reporter; private readonly IHomeDirectoryProvider _home; private readonly Repository _repository; - public GitService(ILogger logger, IHomeDirectoryProvider home, GlobalSettings globals) + public GitService(IReporter reporter, IHomeDirectoryProvider home, GlobalSettings globals) { - Guard.IsNotNull(logger); + Guard.IsNotNull(reporter); Guard.IsNotNull(home); Guard.IsNotNull(globals); - _logger = logger; + _reporter = reporter; _home = home; var homeDirectory = home.HomeDirectory; BuildFailedException.ThrowIfNot(Repository.IsValid(homeDirectory), $"There is no Git repository at {homeDirectory}"); @@ -180,7 +180,7 @@ public void Stage(params string[] paths) return pathInRepo; }).ToArray(); - _logger.LogDebug("Git: staging {Count} file(s)...", pathsInRepo.Length); + _reporter.Detail(string.Create(CultureInfo.InvariantCulture, $"Staging {pathsInRepo.Length} file(s)...")); Commands.Stage(_repository, pathsInRepo, new StageOptions() { IncludeIgnored = false, ExplicitPathsOptions = new() { ShouldFailOnUnmatchedPath = true } }); } @@ -208,7 +208,7 @@ public void Commit(string message, bool amend = false, bool allowEmpty = false) /// public void UndoLastCommit() { - _logger.LogInformation("Git: undoing last commit..."); + _reporter.Info("Undoing last commit..."); var previousCommit = _repository.Head.Tip.Parents.FirstOrDefault(); BuildFailedException.ThrowIfNot(previousCommit is not null, "Git: cannot reset, there is no commit to go back to."); _repository.Reset(ResetMode.Hard, previousCommit); @@ -234,13 +234,13 @@ public void Push(bool force = false) // https://stackoverflow.com/a/47295101/5753412 // https://github.com/libgit2/libgit2sharp/blob/5085a0c6173cdb2a3fde205330b327a8eb0a26c4/LibGit2Sharp.Tests/PushFixture.cs#L183-L187 // https://github.com/libgit2/libgit2sharp/issues/104#issuecomment-1553347893 - _logger.LogInformation("Git: force pushing changes to '{Remote}'...", remote); + _reporter.Info($"Force pushing changes to '{remote}'..."); var pushRefSpec = string.Format(CultureInfo.InvariantCulture, "+{0}:{0}", _repository.Head.CanonicalName); _repository.Network.Push(_repository.Network.Remotes[remote], pushRefSpec, pushOptions); } else { - _logger.LogInformation("Git: pushing changes to '{Remote}'...", remote); + _reporter.Info($"Pushing changes to '{remote}'..."); _repository.Network.Push(head, pushOptions); } } @@ -254,12 +254,12 @@ private bool TryGetOriginInfo([MaybeNullWhen(false)] out string name, [MaybeNull string? onlyRemoteName = null; string? onlyRemoteUrl = null; var isFirst = true; - _logger.LogDebug("Git: looking for origin remote..."); + _reporter.Detail("Looking for origin remote..."); foreach (var remote in _repository.Network.Remotes) { using (remote) { - _logger.LogDebug("Git: '{Name}' ({Url})", remote.Name, remote.Url); + _reporter.Detail($" '{remote.Name}' ({remote.Url})"); if (remote.Name == "origin") { originName = remote.Name; @@ -286,7 +286,7 @@ private bool TryGetOriginInfo([MaybeNullWhen(false)] out string name, [MaybeNull url = originUrl ?? onlyRemoteUrl; if (name is null || url is null) { - _logger.LogDebug("Git: origin remote not found."); + _reporter.Detail("Origin remote not found."); return false; } @@ -297,7 +297,7 @@ private bool TryGetOriginInfo([MaybeNullWhen(false)] out string name, [MaybeNull url = url[..^4]; } - _logger.LogDebug("Git: origin remote is '{Name}' ({Url})", name, url); + _reporter.Detail($"Origin remote is '{name}' ({url})"); return true; } @@ -312,24 +312,24 @@ private string FindMainBranch(string origin, string configuredMainBranch) var configuredValue = string.Empty; if (haveConfiguredMainBranch) { - _logger.LogDebug("Git: looking for main branch on remote '{Origin}' (configured value is '{Configured}')...", origin, configuredMainBranch); + _reporter.Detail($"Looking for main branch on remote '{origin}' (configured value is '{configuredMainBranch}')..."); configuredValue = $"{origin}/{configuredMainBranch}"; } else { - _logger.LogDebug("Git: looking for main branch on remote '{Origin}' (no configured value)...", origin); + _reporter.Detail($"Looking for main branch on remote '{origin}' (no configured value)..."); } foreach (var branch in _repository.Branches.Select(static x => x.FriendlyName)) { if (haveConfiguredMainBranch && branch == configuredValue) { - _logger.LogDebug("Git: '{Branch}' <-- configured value", branch); + _reporter.Detail($" '{branch}' <-- configured value"); mainBranchFound = true; } else { - _logger.LogDebug("Git: '{Branch}'", branch); + _reporter.Detail($" '{branch}'"); if (branch == mainValue) { mainFound = true; @@ -348,11 +348,11 @@ private string FindMainBranch(string origin, string configuredMainBranch) if (mainBranch is null) { - _logger.LogDebug("Git: main branch not found on remote '{Origin}'.", origin); + _reporter.Detail($"Main branch not found on remote '{origin}'."); return string.Empty; } - _logger.LogDebug("Git: main branch '{Branch}' found on remote '{Origin}'.", mainBranch, origin); + _reporter.Detail($"Main branch '{mainBranch}' found on remote '{origin}'."); return mainBranch; } } diff --git a/src/Buildvana.Tool/Services/PublicApiFiles/PublicApiFilesService.cs b/src/Buildvana.Tool/Services/PublicApiFiles/PublicApiFilesService.cs index d73ac0f..4f8f96d 100644 --- a/src/Buildvana.Tool/Services/PublicApiFiles/PublicApiFilesService.cs +++ b/src/Buildvana.Tool/Services/PublicApiFiles/PublicApiFilesService.cs @@ -6,11 +6,11 @@ using System.IO; using System.Linq; using System.Text; +using Buildvana.Core.ConsoleOutput; using Buildvana.Core.HomeDirectory; using Buildvana.Tool.Utilities; using CommunityToolkit.Diagnostics; using Louis.Collections; -using Microsoft.Extensions.Logging; namespace Buildvana.Tool.Services.PublicApiFiles; @@ -22,17 +22,17 @@ internal sealed class PublicApiFilesService private const string RemovedPrefix = "*REMOVED*"; private readonly IHomeDirectoryProvider _home; - private readonly ILogger _logger; + private readonly IReporter _reporter; /// /// Initializes a new instance of the class. /// - public PublicApiFilesService(IHomeDirectoryProvider home, ILogger logger) + public PublicApiFilesService(IHomeDirectoryProvider home, IReporter reporter) { Guard.IsNotNull(home); - Guard.IsNotNull(logger); + Guard.IsNotNull(reporter); _home = home; - _logger = logger; + _reporter = reporter; } /// @@ -46,12 +46,12 @@ public PublicApiFilesService(IHomeDirectoryProvider home, ILogger public ApiChangeKind GetApiChangeKind() { - _logger.LogInformation("Computing API change kind according to unshipped public API files..."); + _reporter.Info("Computing API change kind according to unshipped public API files..."); var result = ApiChangeKind.None; foreach (var unshippedPath in GetAllPublicApiFilePairs().Select(pair => pair.UnshippedPath)) { var fileResult = GetApiChangeKind(unshippedPath); - _logger.LogDebug("{UnshippedPath} -> {Result}", unshippedPath, fileResult); + _reporter.Detail($"{unshippedPath} -> {fileResult}"); if (fileResult == ApiChangeKind.Breaking) { return ApiChangeKind.Breaking; @@ -72,10 +72,10 @@ public ApiChangeKind GetApiChangeKind() /// An enumeration of the modified files. public IEnumerable TransferAllPublicApisToShipped() { - _logger.LogInformation("Updating public API files..."); + _reporter.Info("Updating public API files..."); foreach (var (unshippedPath, shippedPath) in GetAllPublicApiFilePairs()) { - _logger.LogDebug("Updating {ShippedPath}...", shippedPath); + _reporter.Detail($"Updating {shippedPath}..."); if (!TransferPublicApisToShipped(unshippedPath, shippedPath)) { continue; diff --git a/src/Buildvana.Tool/Services/SelfReferenceUpdater.cs b/src/Buildvana.Tool/Services/SelfReferenceUpdater.cs index 15f7b56..50083ca 100644 --- a/src/Buildvana.Tool/Services/SelfReferenceUpdater.cs +++ b/src/Buildvana.Tool/Services/SelfReferenceUpdater.cs @@ -3,15 +3,16 @@ using System; using System.Collections.Generic; +using System.Globalization; using System.IO; using System.Text; using System.Text.RegularExpressions; +using Buildvana.Core.ConsoleOutput; using Buildvana.Core.HomeDirectory; using Buildvana.Core.Json; using Buildvana.Tool.Services.Versioning; using Buildvana.Tool.Utilities; using CommunityToolkit.Diagnostics; -using Microsoft.Extensions.Logging; namespace Buildvana.Tool.Services; @@ -34,23 +35,23 @@ namespace Buildvana.Tool.Services; /// internal sealed class SelfReferenceUpdater { - private readonly ILogger _logger; + private readonly IReporter _reporter; private readonly IHomeDirectoryProvider _home; private readonly IJsonHelper _jsonHelper; private readonly VersionService _version; private readonly (string RelativePath, Func, bool> Update)[] _targets; public SelfReferenceUpdater( - ILogger logger, + IReporter reporter, IHomeDirectoryProvider home, IJsonHelper jsonHelper, VersionService version) { - Guard.IsNotNull(logger); + Guard.IsNotNull(reporter); Guard.IsNotNull(home); Guard.IsNotNull(jsonHelper); Guard.IsNotNull(version); - _logger = logger; + _reporter = reporter; _home = home; _jsonHelper = jsonHelper; _version = version; @@ -74,14 +75,13 @@ public IReadOnlyList UpdateReferences(string artifactsPath) var produced = DiscoverProducedPackages(artifactsPath); if (produced.Count == 0) { - _logger.LogInformation("Self-reference update: no produced packages were found in the artifacts directory."); + _reporter.Info("Self-reference update: no produced packages were found in the artifacts directory."); return []; } - _logger.LogInformation( - "Self-reference update: {Count} produced package(s) detected: {Packages}.", - produced.Count, - string.Join(", ", produced.Keys)); + _reporter.Info(string.Create( + CultureInfo.InvariantCulture, + $"Self-reference update: {produced.Count} produced package(s) detected: {string.Join(", ", produced.Keys)}.")); var modified = new List(); foreach (var (relativePath, update) in _targets) @@ -95,7 +95,7 @@ public IReadOnlyList UpdateReferences(string artifactsPath) if (update(path, produced)) { - _logger.LogInformation("Self-reference update: rewrote {RelativePath}.", relativePath); + _reporter.Info($"Self-reference update: rewrote {relativePath}."); modified.Add(path); } } @@ -118,7 +118,7 @@ private Dictionary DiscoverProducedPackages(string artifactsPath var fileName = Path.GetFileName(path); if (!fileName.EndsWith(suffix, StringComparison.OrdinalIgnoreCase)) { - _logger.LogDebug("Self-reference update: skipping '{FileName}' (version does not match '{Version}').", fileName, version); + _reporter.Detail($"Self-reference update: skipping '{fileName}' (version does not match '{version}')."); continue; } @@ -245,10 +245,7 @@ private bool IsRewritable(string existing, string id, string newVersion) // Don't rewrite property references like $(SomeVersion) — they'd silently lose their indirection. if (existing.Contains("$(", StringComparison.Ordinal)) { - _logger.LogDebug( - "Self-reference update: leaving property-reference version '{Existing}' on package '{Id}' unchanged.", - existing, - id); + _reporter.Detail($"Self-reference update: leaving property-reference version '{existing}' on package '{id}' unchanged."); return false; } diff --git a/src/Buildvana.Tool/Services/ServerAdapters/Internal/GitHub/GitHubServerAdapter.cs b/src/Buildvana.Tool/Services/ServerAdapters/Internal/GitHub/GitHubServerAdapter.cs index 9ccab8c..0a059e2 100644 --- a/src/Buildvana.Tool/Services/ServerAdapters/Internal/GitHub/GitHubServerAdapter.cs +++ b/src/Buildvana.Tool/Services/ServerAdapters/Internal/GitHub/GitHubServerAdapter.cs @@ -6,12 +6,12 @@ using System.Text; using System.Threading.Tasks; using Buildvana.Core; +using Buildvana.Core.ConsoleOutput; using Buildvana.Tool.Configuration; using Buildvana.Tool.Services.Git; using Buildvana.Tool.Services.Versioning; using CommunityToolkit.Diagnostics; using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Logging; using Octokit; namespace Buildvana.Tool.Services.ServerAdapters.Internal.GitHub; @@ -22,7 +22,7 @@ namespace Buildvana.Tool.Services.ServerAdapters.Internal.GitHub; internal sealed class GitHubServerAdapter : ServerAdapter { private readonly IServiceProvider _services; - private readonly ILogger _logger; + private readonly IReporter _reporter; private readonly VersionService _version; private readonly GitService _git; @@ -31,7 +31,7 @@ internal sealed class GitHubServerAdapter : ServerAdapter private GitHubServerAdapter(IServiceProvider services) { _services = services; - _logger = services.GetRequiredService>(); + _reporter = services.GetRequiredService(); _version = services.GetRequiredService(); _git = services.GetRequiredService(); BuildFailedException.ThrowIfNot(GitUrlInfo.TryCreate(_git.OriginUrl, out var originInfo), $"Couldn't get information from origin URL '{_git.OriginUrl}'."); @@ -100,7 +100,7 @@ public static void SetActionsStepOutput(string name, string value) /// public override async Task IsPrivateRepositoryAsync() { - _logger.LogInformation("Fetching repository information..."); + _reporter.Info("Fetching repository information..."); var client = CreateGitHubClient(); var repository = await client.Repository.Get(RepositoryOwner, RepositoryName).ConfigureAwait(false); return repository.Private; @@ -137,7 +137,7 @@ public override async Task CreateReleaseAsync() // Create the release as a draft first, so if the token has no permissions we can bail out early var tag = _version.CurrentStr; var client = CreateGitHubClient(); - _logger.LogInformation("Creating a provisional draft release..."); + _reporter.Info("Creating a provisional draft release..."); var newRelease = new NewRelease(tag) { Name = $"{tag} [provisional]", @@ -163,7 +163,7 @@ public async Task PublishReleaseAsync(Release release, string targetCommitish) Guard.IsNotNullOrEmpty(targetCommitish); var tag = _version.CurrentStr; var client = CreateGitHubClient(); - _logger.LogInformation("Generating release notes for {Tag}...", tag); + _reporter.Info($"Generating release notes for {tag}..."); var releaseNotesRequest = new GenerateReleaseNotesRequest(tag) { TargetCommitish = targetCommitish, @@ -173,7 +173,7 @@ public async Task PublishReleaseAsync(Release release, string targetCommitish) var body = $"We also have a [human-curated changelog]({GetFileUrl("CHANGELOG.md", _git.MainBranch)}).\n\n---\n\n" + generateNotesResponse.Body; - _logger.LogInformation("Publishing the previously created release as {Tag} (target {TargetCommitish})...", tag, targetCommitish); + _reporter.Info($"Publishing the previously created release as {tag} (target {targetCommitish})..."); var update = release.ToUpdate(); update.TagName = tag; update.Name = tag; @@ -194,7 +194,7 @@ public async Task PublishReleaseAsync(Release release, string targetCommitish) public async Task DeleteReleaseAsync(Release release, string? tagName) { Guard.IsNotNull(release); - _logger.LogInformation("Deleting the previously created release..."); + _reporter.Info("Deleting the previously created release..."); var client = CreateGitHubClient(); await client.Repository.Release.Delete(RepositoryOwner, RepositoryName, release.Id).ConfigureAwait(false); if (string.IsNullOrEmpty(tagName)) @@ -203,18 +203,18 @@ public async Task DeleteReleaseAsync(Release release, string? tagName) } var reference = "refs/tags/" + tagName; - _logger.LogInformation("Looking for reference '{Reference}' in GitHub repository...", reference); + _reporter.Info($"Looking for reference '{reference}' in GitHub repository..."); try { _ = await client.Git.Reference.Get(RepositoryOwner, RepositoryName, reference).ConfigureAwait(false); } catch (NotFoundException) { - _logger.LogInformation("Reference '{Reference}' not found in GitHub repository.", reference); + _reporter.Info($"Reference '{reference}' not found in GitHub repository."); return; } - _logger.LogInformation("Deleting reference '{Reference}' in GitHub repository...", reference); + _reporter.Info($"Deleting reference '{reference}' in GitHub repository..."); await client.Git.Reference.Delete(RepositoryOwner, RepositoryName, reference).ConfigureAwait(false); } @@ -229,7 +229,7 @@ public async Task DeleteReleaseAsync(Release release, string? tagName) public async Task UploadReleaseAssetAsync(Release release, string path, string mimeType, string description) { var client = CreateGitHubClient(); - _logger.LogDebug("Uploading asset {Path}...", path); + _reporter.Detail($"Uploading asset {path}..."); ReleaseAsset asset; var assetContents = File.OpenRead(path); await using (assetContents.ConfigureAwait(false)) @@ -246,14 +246,14 @@ public async Task UploadReleaseAssetAsync(Release release, string path, string m if (!string.IsNullOrEmpty(description)) { - _logger.LogDebug("Updating asset label..."); + _reporter.Detail("Updating asset label..."); var update = asset.ToUpdate(); update.Label = description; _ = await client.Repository.Release.EditAsset(RepositoryOwner, RepositoryName, asset.Id, update).ConfigureAwait(false); } else { - _logger.LogDebug("Skipping label update: asset has no description."); + _reporter.Detail("Skipping label update: asset has no description."); } } diff --git a/src/Buildvana.Tool/Services/ServerAdapters/Internal/GitHub/GitHubServerRelease.cs b/src/Buildvana.Tool/Services/ServerAdapters/Internal/GitHub/GitHubServerRelease.cs index 469c95b..685fd7c 100644 --- a/src/Buildvana.Tool/Services/ServerAdapters/Internal/GitHub/GitHubServerRelease.cs +++ b/src/Buildvana.Tool/Services/ServerAdapters/Internal/GitHub/GitHubServerRelease.cs @@ -3,12 +3,13 @@ using System; using System.Collections.Generic; +using System.Globalization; using System.IO; using System.Threading.Tasks; +using Buildvana.Core.ConsoleOutput; using Buildvana.Tool.Services.Versioning; using CommunityToolkit.Diagnostics; using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Logging; using Octokit; namespace Buildvana.Tool.Services.ServerAdapters.Internal.GitHub; @@ -19,7 +20,7 @@ namespace Buildvana.Tool.Services.ServerAdapters.Internal.GitHub; internal sealed class GitHubServerRelease : ServerRelease { private readonly GitHubServerAdapter _server; - private readonly ILogger _logger; + private readonly IReporter _reporter; private readonly VersionService _version; private readonly Release _gitHubRelease; @@ -33,7 +34,7 @@ private GitHubServerRelease(GitHubServerAdapter server, IServiceProvider service Guard.IsNotNull(gitHubRelease); _server = server; - _logger = services.GetRequiredService>(); + _reporter = services.GetRequiredService(); _version = services.GetRequiredService(); _gitHubRelease = gitHubRelease; @@ -66,18 +67,15 @@ protected override async Task DoPublishAsync(IReadOnlyList assets) foreach (var asset in assets) { i++; - _logger.LogInformation( - "Uploading asset {Index} of {Count}: {Filename} ({Description})...", - i, - assetCount, - Path.GetFileName(asset.Path), - asset.Description); + _reporter.Info(string.Create( + CultureInfo.InvariantCulture, + $"Uploading asset {i} of {assetCount}: {Path.GetFileName(asset.Path)} ({asset.Description})...")); await _server.UploadReleaseAssetAsync(_gitHubRelease, asset.Path, asset.MimeType, asset.Description).ConfigureAwait(false); } } else { - _logger.LogInformation("Asset upload skipped: no release assets defined."); + _reporter.Info("Asset upload skipped: no release assets defined."); } await _server.PublishReleaseAsync(_gitHubRelease, ReleaseCommitSha).ConfigureAwait(false); diff --git a/src/Buildvana.Tool/Services/ServerAdapters/ServerRelease.cs b/src/Buildvana.Tool/Services/ServerAdapters/ServerRelease.cs index bece2ed..f206c86 100644 --- a/src/Buildvana.Tool/Services/ServerAdapters/ServerRelease.cs +++ b/src/Buildvana.Tool/Services/ServerAdapters/ServerRelease.cs @@ -5,12 +5,12 @@ using System.Collections.Generic; using System.IO; using System.Threading.Tasks; +using Buildvana.Core.ConsoleOutput; using Buildvana.Tool.Services.Git; using Buildvana.Tool.Services.Versioning; using CommunityToolkit.Diagnostics; using Louis.Diagnostics; using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Logging; namespace Buildvana.Tool.Services.ServerAdapters; @@ -19,7 +19,7 @@ namespace Buildvana.Tool.Services.ServerAdapters; /// internal abstract partial class ServerRelease : IAsyncDisposable { - private readonly ILogger _logger; + private readonly IReporter _reporter; private readonly GitService _git; private readonly VersionService _version; private readonly Stack> _rollbackActions = new(); @@ -35,7 +35,7 @@ private protected ServerRelease(IServiceProvider services) { Guard.IsNotNull(services); - _logger = services.GetRequiredService().CreateLogger(GetType()); + _reporter = services.GetRequiredService(); _git = services.GetRequiredService(); _version = services.GetRequiredService(); } @@ -77,12 +77,12 @@ public void EnsureReleaseCommit() return; } - _logger.LogInformation("Creating release commit..."); + _reporter.Info("Creating release commit..."); _git.Commit("Prepare release [skip ci]", allowEmpty: true); // Git height has changed _version.Update(); - _logger.LogInformation("Version changed to {Version}", _version.CurrentStr); + _reporter.Info($"Version changed to {_version.CurrentStr}"); _git.Commit($"Prepare release {_version.CurrentStr} [skip ci]", amend: true, allowEmpty: true); _repositoryUpdated = true; _releaseCommitSha = _git.HeadSha; @@ -125,7 +125,7 @@ public void UpdateRepository(params string[] files) EnsureReleaseCommit(); _git.Stage(files); - _logger.LogInformation("Amending release commit..."); + _reporter.Info("Amending release commit..."); _git.Commit($"Prepare release {_version.CurrentStr} [skip ci]", amend: true, allowEmpty: true); _releaseCommitSha = _git.HeadSha; } @@ -156,7 +156,7 @@ public void AddPostReleaseCommit(string message, params string[] files) EnsureReleaseCommit(); _git.Stage(files); - _logger.LogInformation("Committing post-release changed files..."); + _reporter.Info("Committing post-release changed files..."); _git.Commit(message); _postReleaseCommits++; @@ -171,7 +171,7 @@ public void PushUpdates() if (!_repositoryUpdated) { - _logger.LogInformation("Repository unchanged, no commit to push."); + _reporter.Info("Repository unchanged, no commit to push."); return; } @@ -228,12 +228,7 @@ public async ValueTask DisposeAsync() } catch (Exception ex) when (!ex.IsCriticalError()) { - _logger.LogWarning( - "{ExceptionType} in release rollback action: {Message}{NewLine}{StackTrace}", - ex.GetType().Name, - ex.Message, - Environment.NewLine, - ex.StackTrace); + _reporter.Warning($"{ex.GetType().Name} in release rollback action: {ex.Message}{Environment.NewLine}{ex.StackTrace}"); } } diff --git a/src/Buildvana.Tool/Services/Versioning/VersionService.cs b/src/Buildvana.Tool/Services/Versioning/VersionService.cs index da7cc9c..4228b6f 100644 --- a/src/Buildvana.Tool/Services/Versioning/VersionService.cs +++ b/src/Buildvana.Tool/Services/Versioning/VersionService.cs @@ -2,12 +2,12 @@ // See the LICENSE file in the project root for full license information. using Buildvana.Core; +using Buildvana.Core.ConsoleOutput; using Buildvana.Core.Json; using Buildvana.Core.Process; using Buildvana.Tool.Services.Git; using Buildvana.Tool.Services.PublicApiFiles; using CommunityToolkit.Diagnostics; -using Microsoft.Extensions.Logging; using NuGet.Versioning; namespace Buildvana.Tool.Services.Versioning; @@ -17,7 +17,7 @@ namespace Buildvana.Tool.Services.Versioning; /// internal sealed class VersionService { - private readonly ILogger _logger; + private readonly IReporter _reporter; private readonly IJsonHelper _jsonHelper; private readonly IProcessRunner _processRunner; private readonly PublicApiFilesService _publicApiFiles; @@ -26,18 +26,18 @@ internal sealed class VersionService /// Initializes a new instance of the class. /// public VersionService( - ILogger logger, + IReporter reporter, IJsonHelper jsonHelper, IProcessRunner processRunner, GitService git, PublicApiFilesService publicApiFiles) { - Guard.IsNotNull(logger); + Guard.IsNotNull(reporter); Guard.IsNotNull(jsonHelper); Guard.IsNotNull(processRunner); Guard.IsNotNull(git); Guard.IsNotNull(publicApiFiles); - _logger = logger; + _reporter = reporter; _jsonHelper = jsonHelper; _processRunner = processRunner; _publicApiFiles = publicApiFiles; @@ -121,14 +121,12 @@ public VersionSpecChange ComputeVersionSpecChange(VersionSpecChange requestedCha : Current.Major > LatestStable.Major ? VersionIncrement.Major : Current.Minor > LatestStable.Minor ? VersionIncrement.Minor : VersionIncrement.None; - _logger.LogInformation("Current version increment: {Increment}", currentVersionIncrement); + _reporter.Info($"Current version increment: {currentVersionIncrement}"); // Determine the kind of change in public API var publicApiChangeKind = checkPublicApiFiles ? _publicApiFiles.GetApiChangeKind() : ApiChangeKind.None; - _logger.LogInformation( - "Public API change kind: {Kind}{NotCheckedSuffix}", - publicApiChangeKind, - checkPublicApiFiles ? string.Empty : " (not checked)"); + var notCheckedSuffix = checkPublicApiFiles ? string.Empty : " (not checked)"; + _reporter.Info($"Public API change kind: {publicApiChangeKind}{notCheckedSuffix}"); // Determine the version increment required by SemVer rules // When the major version is 0, "anything MAY change" according to SemVer; @@ -139,16 +137,16 @@ public VersionSpecChange ComputeVersionSpecChange(VersionSpecChange requestedCha ApiChangeKind.Additive => isMajorVersionZero ? VersionIncrement.None : VersionIncrement.Minor, _ => VersionIncrement.None, }; - _logger.LogInformation("Required version increment according to Semantic Versioning rules: {Increment}", semanticVersionIncrement); + _reporter.Info($"Required version increment according to Semantic Versioning rules: {semanticVersionIncrement}"); // Determine the requested version increment, if any. - _logger.LogInformation("Requested version spec change: {Change}", requestedChange); + _reporter.Info($"Requested version spec change: {requestedChange}"); var requestedVersionIncrement = requestedChange switch { VersionSpecChange.Major => VersionIncrement.Major, VersionSpecChange.Minor => VersionIncrement.Minor, _ => VersionIncrement.None, }; - _logger.LogInformation("Requested version increment: {Increment}.", requestedVersionIncrement); + _reporter.Info($"Requested version increment: {requestedVersionIncrement}."); // Adjust requested version increment to follow SemVer rules if (semanticVersionIncrement > requestedVersionIncrement) @@ -158,7 +156,7 @@ public VersionSpecChange ComputeVersionSpecChange(VersionSpecChange requestedCha // Determine the kind of version increment actually required var actualVersionIncrement = requestedVersionIncrement > currentVersionIncrement ? requestedVersionIncrement : VersionIncrement.None; - _logger.LogInformation("Required version increment with respect to current version: {Increment}", actualVersionIncrement); + _reporter.Info($"Required version increment with respect to current version: {actualVersionIncrement}"); // Determine the actual version spec change to apply: // - forget any increment-related change (already accounted for via requestedVersionIncrement) @@ -172,7 +170,7 @@ public VersionSpecChange ComputeVersionSpecChange(VersionSpecChange requestedCha VersionIncrement.Minor => VersionSpecChange.Minor, _ => actualChange, }; - _logger.LogInformation("Actual version spec change: {Change}.", actualChange); + _reporter.Info($"Actual version spec change: {actualChange}."); return actualChange; } diff --git a/src/Buildvana.Tool/Subcommands/ReleaseCommand.cs b/src/Buildvana.Tool/Subcommands/ReleaseCommand.cs index 82d866c..0cc8d11 100644 --- a/src/Buildvana.Tool/Subcommands/ReleaseCommand.cs +++ b/src/Buildvana.Tool/Subcommands/ReleaseCommand.cs @@ -3,11 +3,13 @@ using System; using System.ComponentModel; +using System.Globalization; using System.IO; using System.Linq; using System.Threading; using System.Threading.Tasks; using Buildvana.Core; +using Buildvana.Core.ConsoleOutput; using Buildvana.Core.HomeDirectory; using Buildvana.Core.Json; using Buildvana.Tool.Build; @@ -20,7 +22,6 @@ using Buildvana.Tool.Services.Versioning; using Buildvana.Tool.Utilities; using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Logging; namespace Buildvana.Tool.Subcommands; @@ -30,13 +31,15 @@ internal sealed class ReleaseCommand(IServiceProvider services, ReleaseSettings { public async Task ExecuteAsync(CancellationToken cancellationToken) { + var reporter = services.GetRequiredService(); + using var activity = reporter.BeginActivity("Release"); + var configuration = settings.ResolveConfiguration(); var artifactsPath = Path.Combine(CommonPaths.AllArtifacts, configuration); // Verification pass (Clean→Test), mirroring today's [IsDependentOn(TestTask)] chain. await pipeline.RunThroughAsync(BuildStep.Test, configuration, cancellationToken).ConfigureAwait(false); - var logger = services.GetRequiredService().CreateLogger("Release"); var home = services.GetRequiredService(); var jsonHelper = services.GetRequiredService(); var server = services.GetRequiredService(); @@ -55,19 +58,19 @@ public async Task ExecuteAsync(CancellationToken cancellationToken) // Ensure that the CI bot identity is used for commits, if not already set. git.CommitterIdentity ??= server.CIBotIdentity ?? throw new BuildFailedException("Cannot determine a committer identity for release commits. Configure git config user.name/user.email before running this task."); - logger.LogInformation("Using committer identity: {Name} <{Email}>", git.CommitterIdentity.Name, git.CommitterIdentity.Email); + reporter.Info($"Using committer identity: {git.CommitterIdentity.Name} <{git.CommitterIdentity.Email}>"); // Set fallback Git credentials if the server adapter can provide them. var pushUsername = server.PushUsername; var pushPassword = server.PushPassword; if (pushUsername is not null && pushPassword is not null) { - logger.LogInformation("Fallback push credentials provided by the server adapter (protocol username: '{Username}').", pushUsername); + reporter.Info($"Fallback push credentials provided by the server adapter (protocol username: '{pushUsername}')."); git.PushCredentialsFallback = new(pushUsername, pushPassword); } else { - logger.LogWarning("No push credentials provided by the server adapter. Push operations may fail if the repository is not already authenticated."); + reporter.Warning("No push credentials provided by the server adapter. Push operations may fail if the repository is not already authenticated."); } // Perform an initial versioning consistency check. @@ -91,20 +94,20 @@ public async Task ExecuteAsync(CancellationToken cancellationToken) var previousVersionSpec = versionFile.VersionSpec; if (versionFile.ApplyVersionSpecChange(versionSpecChange)) { - logger.LogInformation("Version spec changed from {Previous} to {New}.", previousVersionSpec, versionFile.VersionSpec); + reporter.Info($"Version spec changed from {previousVersionSpec} to {versionFile.VersionSpec}."); versionFile.Save(); release.UpdateRepository(versionFile.Path); } else { - logger.LogInformation("Version spec not changed."); + reporter.Info("Version spec not changed."); } } // Update public API files only when releasing a stable version if (version.IsPrerelease) { - logger.LogInformation("Public API update skipped: not needed on prerelease."); + reporter.Info("Public API update skipped: not needed on prerelease."); } else { @@ -112,13 +115,13 @@ public async Task ExecuteAsync(CancellationToken cancellationToken) switch (modified.Length) { case 0: - logger.LogInformation("No public API files were modified."); + reporter.Info("No public API files were modified."); break; case 1: - logger.LogInformation("1 public API file was modified."); + reporter.Info("1 public API file was modified."); break; default: - logger.LogInformation("{Count} public API files were modified.", modified.Length); + reporter.Info(string.Create(CultureInfo.InvariantCulture, $"{modified.Length} public API files were modified.")); break; } @@ -132,7 +135,7 @@ public async Task ExecuteAsync(CancellationToken cancellationToken) var changelogUpdated = false; if (!changelog.Exists) { - logger.LogInformation("Changelog update skipped: {Path} not found.", ChangelogService.FileName); + reporter.Info($"Changelog update skipped: {ChangelogService.FileName} not found."); } else if (!version.IsPrerelease || settings.ResolveUnstableChangelog()) { @@ -142,11 +145,11 @@ public async Task ExecuteAsync(CancellationToken cancellationToken) changelog.HasUnreleasedChanges(), "Changelog check failed: the \"Unreleased changes\" section is empty or only contains sub-section headings."); - logger.LogInformation("Changelog check successful: the \"Unreleased changes\" section is not empty."); + reporter.Info("Changelog check successful: the \"Unreleased changes\" section is not empty."); } else { - logger.LogInformation("Changelog check skipped: option 'requireChangelog' is false."); + reporter.Info("Changelog check skipped: option 'requireChangelog' is false."); } // Update the changelog and commit the change before building. @@ -157,7 +160,7 @@ public async Task ExecuteAsync(CancellationToken cancellationToken) } else { - logger.LogInformation("Changelog update skipped: not needed on prerelease."); + reporter.Info("Changelog update skipped: not needed on prerelease."); } // At this point we know what the actual published version will be. @@ -180,7 +183,7 @@ public async Task ExecuteAsync(CancellationToken cancellationToken) } else { - logger.LogInformation("Changelog section title update skipped: changelog has not been updated."); + reporter.Info("Changelog section title update skipped: changelog has not been updated."); } // Update in-tree references to packages produced by this release (dogfooding). @@ -195,13 +198,13 @@ public async Task ExecuteAsync(CancellationToken cancellationToken) switch (selfReferenceUpdates.Count) { case 0: - logger.LogInformation("No self-referenced files were modified."); + reporter.Info("No self-referenced files were modified."); break; case 1: - logger.LogInformation("1 self-referenced file was modified."); + reporter.Info("1 self-referenced file was modified."); break; default: - logger.LogInformation("{Count} self-referenced files were modified.", selfReferenceUpdates.Count); + reporter.Info(string.Create(CultureInfo.InvariantCulture, $"{selfReferenceUpdates.Count} self-referenced files were modified.")); break; } @@ -214,7 +217,7 @@ public async Task ExecuteAsync(CancellationToken cancellationToken) } else { - logger.LogInformation("Self-reference update skipped: option 'dogfood' is false."); + reporter.Info("Self-reference update skipped: option 'dogfood' is false."); } release.PushUpdates(); @@ -223,10 +226,10 @@ public async Task ExecuteAsync(CancellationToken cancellationToken) await dotnet.NuGetPushAllAsync(artifactsPath, cancellationToken).ConfigureAwait(false); // Gather build assets from Buildvana.Sdk release asset lists - logger.LogInformation("Reading release asset lists..."); + reporter.Info("Reading release asset lists..."); foreach (var path in FileSystemHelper.EnumerateFiles(artifactsPath, "*.assets.txt")) { - logger.LogDebug("Reading release asset list {Path}...", path); + reporter.Detail($"Reading release asset list {path}..."); var i = 0; await foreach (var line in File.ReadLinesAsync(path, cancellationToken).ConfigureAwait(false)) { @@ -234,13 +237,13 @@ public async Task ExecuteAsync(CancellationToken cancellationToken) var parts = line.Split('\t'); if (parts.Length != 3) { - logger.LogWarning("Release asset list {Path}, line #{LineNumber}: invalid line '{Line}'", path, i, line); + reporter.Warning(string.Create(CultureInfo.InvariantCulture, $"Release asset list {path}, line #{i}: invalid line '{line}'")); continue; } if (!File.Exists(parts[0])) { - logger.LogWarning("Release asset list {Path}, line #{LineNumber}: asset not found '{Asset}'", path, i, parts[0]); + reporter.Warning(string.Create(CultureInfo.InvariantCulture, $"Release asset list {path}, line #{i}: asset not found '{parts[0]}'")); continue; } @@ -264,17 +267,17 @@ public async Task ExecuteAsync(CancellationToken cancellationToken) { if (version.IsPrerelease) { - logger.LogInformation("Documentation generation skipped: not needed on prerelease."); + reporter.Info("Documentation generation skipped: not needed on prerelease."); } else if (git.CurrentBranch != git.MainBranch) { - logger.LogInformation("Documentation generation skipped: releasing from '{Current}', not '{Main}'.", git.CurrentBranch, git.MainBranch); + reporter.Info($"Documentation generation skipped: releasing from '{git.CurrentBranch}', not '{git.MainBranch}'."); } else { - logger.LogInformation("Generating documentation web pages..."); + reporter.Info("Generating documentation web pages..."); await docfx.GenerateSiteAsync().ConfigureAwait(false); - logger.LogInformation("Generating documentation PDF files..."); + reporter.Info("Generating documentation PDF files..."); await docfx.GeneratePdfsAsync().ConfigureAwait(false); } } @@ -283,6 +286,7 @@ public async Task ExecuteAsync(CancellationToken cancellationToken) await release.PublishAsync().ConfigureAwait(false); } + activity.Complete(); return 0; } } diff --git a/src/Buildvana.Tool/Utilities/FileSystemHelper.cs b/src/Buildvana.Tool/Utilities/FileSystemHelper.cs index e97770d..c22647b 100644 --- a/src/Buildvana.Tool/Utilities/FileSystemHelper.cs +++ b/src/Buildvana.Tool/Utilities/FileSystemHelper.cs @@ -5,10 +5,10 @@ using System.Collections.Generic; using System.IO; using System.Linq; +using Buildvana.Core.ConsoleOutput; using CommunityToolkit.Diagnostics; using Microsoft.Extensions.FileSystemGlobbing; using Microsoft.Extensions.FileSystemGlobbing.Abstractions; -using Microsoft.Extensions.Logging; namespace Buildvana.Tool.Utilities; @@ -39,18 +39,18 @@ public static bool FileExists(string path) /// Recursively delete a directory and all its contents. No-op if the directory does not exist. /// /// The directory to delete. - /// Optional logger. When provided, logs Information on actual deletion - /// and Debug when the directory does not exist and is therefore skipped. - public static void DeleteDirectory(string path, ILogger? logger = null) + /// Optional reporter. When provided, reports at Info on actual deletion + /// and at Detail when the directory does not exist and is therefore skipped. + public static void DeleteDirectory(string path, IReporter? reporter = null) { Guard.IsNotNull(path); if (!Directory.Exists(path)) { - logger?.LogDebug("Skipping non-existent directory: {Path}", path); + reporter?.Detail($"Skipping non-existent directory: {path}"); return; } - logger?.LogInformation("Deleting directory: {Path}", path); + reporter?.Info($"Deleting directory: {path}"); Directory.Delete(path, recursive: true); }