diff --git a/src/ModularPipelines.Azure.Pipelines/AzurePipeline.cs b/src/ModularPipelines.Azure.Pipelines/AzurePipeline.cs index 483ac81958..afdfa6f448 100644 --- a/src/ModularPipelines.Azure.Pipelines/AzurePipeline.cs +++ b/src/ModularPipelines.Azure.Pipelines/AzurePipeline.cs @@ -1,20 +1,25 @@ +using ModularPipelines.Console; using ModularPipelines.Context; -using ModularPipelines.Logging; +using ModularPipelines.Engine; namespace ModularPipelines.Azure.Pipelines; internal class AzurePipeline : IAzurePipeline { private readonly IEnvironmentContext _environment; - private readonly IModuleOutputWriter _outputWriter; + private readonly IModuleOutputBuffer _buffer; + private readonly IBuildSystemFormatter _formatter; - public AzurePipeline(AzurePipelineVariables variables, + public AzurePipeline( + AzurePipelineVariables variables, IEnvironmentContext environment, - IModuleOutputWriterFactory outputWriterFactory) + IConsoleCoordinator consoleCoordinator, + IBuildSystemFormatterProvider formatterProvider) { _environment = environment; Variables = variables; - _outputWriter = outputWriterFactory.Create("AzurePipeline"); + _buffer = consoleCoordinator.GetUnattributedBuffer(); + _formatter = formatterProvider.GetFormatter(); } public bool IsRunningOnAzurePipelines @@ -24,16 +29,40 @@ public bool IsRunningOnAzurePipelines public void WriteLine(string message) { - _outputWriter.WriteLine(message); + _buffer.WriteLine(message); } - public void WriteLineDirect(string message) + public IDisposable BeginSection(string name) { - _outputWriter.WriteLineDirect(message); + return new OutputSection(_buffer, name, _formatter); } - public IDisposable BeginSection(string name) + private sealed class OutputSection : IDisposable { - return _outputWriter.BeginSection(name); + private readonly IModuleOutputBuffer _buffer; + private readonly string _name; + private readonly IBuildSystemFormatter _formatter; + + public OutputSection(IModuleOutputBuffer buffer, string name, IBuildSystemFormatter formatter) + { + _buffer = buffer; + _name = name; + _formatter = formatter; + + var startCommand = formatter.GetStartBlockCommand(name); + if (startCommand != null) + { + _buffer.WriteLine(startCommand); + } + } + + public void Dispose() + { + var endCommand = _formatter.GetEndBlockCommand(_name); + if (endCommand != null) + { + _buffer.WriteLine(endCommand); + } + } } -} \ No newline at end of file +} diff --git a/src/ModularPipelines.Azure.Pipelines/IAzurePipeline.cs b/src/ModularPipelines.Azure.Pipelines/IAzurePipeline.cs index 6a9319bcf3..c9e8ff8b75 100644 --- a/src/ModularPipelines.Azure.Pipelines/IAzurePipeline.cs +++ b/src/ModularPipelines.Azure.Pipelines/IAzurePipeline.cs @@ -1,10 +1,21 @@ -using ModularPipelines.Logging; - namespace ModularPipelines.Azure.Pipelines; -public interface IAzurePipeline : IModuleOutputWriter +public interface IAzurePipeline { public bool IsRunningOnAzurePipelines { get; } public AzurePipelineVariables Variables { get; } -} \ No newline at end of file + + /// + /// Writes a message to the console output. + /// + /// The message to write. + void WriteLine(string message); + + /// + /// Begins a collapsible section in CI output. + /// + /// The section name. + /// A disposable that ends the section when disposed. + IDisposable BeginSection(string name); +} diff --git a/src/ModularPipelines.GitHub/GitHub.cs b/src/ModularPipelines.GitHub/GitHub.cs index 88ef6f3063..df84954ac2 100644 --- a/src/ModularPipelines.GitHub/GitHub.cs +++ b/src/ModularPipelines.GitHub/GitHub.cs @@ -1,6 +1,7 @@ using Microsoft.Extensions.Options; +using ModularPipelines.Console; +using ModularPipelines.Engine; using ModularPipelines.GitHub.Options; -using ModularPipelines.Logging; using Octokit; using Octokit.Internal; @@ -11,7 +12,8 @@ internal class GitHub : IGitHub private readonly GitHubOptions _options; private readonly IHttpMessageHandlerFactory _httpMessageHandlerFactory; private readonly Lazy _client; - private readonly ModuleOutputWriter _outputWriter; + private readonly IModuleOutputBuffer _buffer; + private readonly IBuildSystemFormatter _formatter; public IGitHubClient Client => _client.Value; @@ -24,31 +26,26 @@ public GitHub( IGitHubEnvironmentVariables environmentVariables, IGitHubRepositoryInfo gitHubRepositoryInfo, IHttpMessageHandlerFactory httpMessageHandlerFactory, - IModuleOutputWriterFactory outputWriterFactory, - IModuleLoggerProvider moduleLoggerProvider) + IConsoleCoordinator consoleCoordinator, + IBuildSystemFormatterProvider formatterProvider) { _options = options.Value; _httpMessageHandlerFactory = httpMessageHandlerFactory; EnvironmentVariables = environmentVariables; - _client = new Lazy(InitializeClient); RepositoryInfo = gitHubRepositoryInfo; - _outputWriter = outputWriterFactory.Create("GitHub", moduleLoggerProvider.GetLogger()); + _buffer = consoleCoordinator.GetUnattributedBuffer(); + _formatter = formatterProvider.GetFormatter(); } public void WriteLine(string message) { - _outputWriter.WriteLine(message); - } - - public void WriteLineDirect(string message) - { - _outputWriter.WriteLineDirect(message); + _buffer.WriteLine(message); } public IDisposable BeginSection(string name) { - return _outputWriter.BeginSection(name); + return new OutputSection(this, name, _formatter); } // PRIVATE METHODS @@ -74,4 +71,33 @@ private IGitHubClient InitializeClient() return client; } + + private sealed class OutputSection : IDisposable + { + private readonly GitHub _github; + private readonly string _name; + private readonly IBuildSystemFormatter _formatter; + + public OutputSection(GitHub github, string name, IBuildSystemFormatter formatter) + { + _github = github; + _name = name; + _formatter = formatter; + + var startCommand = formatter.GetStartBlockCommand(name); + if (startCommand != null) + { + _github._buffer.WriteLine(startCommand); + } + } + + public void Dispose() + { + var endCommand = _formatter.GetEndBlockCommand(_name); + if (endCommand != null) + { + _github._buffer.WriteLine(endCommand); + } + } + } } diff --git a/src/ModularPipelines.GitHub/GitHubMarkdownSummaryGenerator.cs b/src/ModularPipelines.GitHub/GitHubMarkdownSummaryGenerator.cs index c85ef25ac4..b1ff96bc32 100644 --- a/src/ModularPipelines.GitHub/GitHubMarkdownSummaryGenerator.cs +++ b/src/ModularPipelines.GitHub/GitHubMarkdownSummaryGenerator.cs @@ -50,7 +50,7 @@ private async Task WriteFile(IPipelineHookContext pipelineContext, string stepSu if (newSize > MaxFileSizeInBytes) { - Console.WriteLine("Appending to the GitHub Step Summary would exceed the 1MB file size limit."); + System.Console.WriteLine("Appending to the GitHub Step Summary would exceed the 1MB file size limit."); return; } diff --git a/src/ModularPipelines.GitHub/GitHubRepositoryInfo.cs b/src/ModularPipelines.GitHub/GitHubRepositoryInfo.cs index 7ef9bcf678..d15588b333 100644 --- a/src/ModularPipelines.GitHub/GitHubRepositoryInfo.cs +++ b/src/ModularPipelines.GitHub/GitHubRepositoryInfo.cs @@ -98,7 +98,7 @@ public async Task InitializeAsync() } catch (Exception e) when (e is not (OutOfMemoryException or StackOverflowException)) { - Console.WriteLine(e); + System.Console.WriteLine(e); } } } diff --git a/src/ModularPipelines.GitHub/IGitHub.cs b/src/ModularPipelines.GitHub/IGitHub.cs index afe7ba1be7..0520d05c4b 100644 --- a/src/ModularPipelines.GitHub/IGitHub.cs +++ b/src/ModularPipelines.GitHub/IGitHub.cs @@ -1,13 +1,25 @@ -using ModularPipelines.Logging; using Octokit; namespace ModularPipelines.GitHub; -public interface IGitHub : IModuleOutputWriter +public interface IGitHub { IGitHubClient Client { get; } IGitHubRepositoryInfo RepositoryInfo { get; } IGitHubEnvironmentVariables EnvironmentVariables { get; } -} \ No newline at end of file + + /// + /// Writes a message to the console output. + /// + /// The message to write. + void WriteLine(string message); + + /// + /// Begins a collapsible section in CI output (GitHub Actions group). + /// + /// The section name. + /// A disposable that ends the section when disposed. + IDisposable BeginSection(string name); +} diff --git a/src/ModularPipelines.TeamCity/ITeamCity.cs b/src/ModularPipelines.TeamCity/ITeamCity.cs index d9738edb36..ab14e359d6 100644 --- a/src/ModularPipelines.TeamCity/ITeamCity.cs +++ b/src/ModularPipelines.TeamCity/ITeamCity.cs @@ -1,8 +1,19 @@ -using ModularPipelines.Logging; - namespace ModularPipelines.TeamCity; -public interface ITeamCity : IModuleOutputWriter +public interface ITeamCity { ITeamCityEnvironmentVariables EnvironmentVariables { get; } -} \ No newline at end of file + + /// + /// Writes a message to the console output. + /// + /// The message to write. + void WriteLine(string message); + + /// + /// Begins a collapsible section in CI output. + /// + /// The section name. + /// A disposable that ends the section when disposed. + IDisposable BeginSection(string name); +} diff --git a/src/ModularPipelines.TeamCity/TeamCity.cs b/src/ModularPipelines.TeamCity/TeamCity.cs index 1302e66901..d797cf3d27 100644 --- a/src/ModularPipelines.TeamCity/TeamCity.cs +++ b/src/ModularPipelines.TeamCity/TeamCity.cs @@ -1,32 +1,61 @@ -using ModularPipelines.Logging; +using ModularPipelines.Console; +using ModularPipelines.Engine; namespace ModularPipelines.TeamCity; internal class TeamCity : ITeamCity { - private readonly ModuleOutputWriter _outputWriter; + private readonly IModuleOutputBuffer _buffer; + private readonly IBuildSystemFormatter _formatter; - public TeamCity(ITeamCityEnvironmentVariables environmentVariables, - IModuleOutputWriterFactory outputWriterFactory) + public TeamCity( + ITeamCityEnvironmentVariables environmentVariables, + IConsoleCoordinator consoleCoordinator, + IBuildSystemFormatterProvider formatterProvider) { EnvironmentVariables = environmentVariables; - _outputWriter = outputWriterFactory.Create("TeamCity"); + _buffer = consoleCoordinator.GetUnattributedBuffer(); + _formatter = formatterProvider.GetFormatter(); } public ITeamCityEnvironmentVariables EnvironmentVariables { get; } public void WriteLine(string message) { - _outputWriter.WriteLine(message); + _buffer.WriteLine(message); } - public void WriteLineDirect(string message) + public IDisposable BeginSection(string name) { - _outputWriter.WriteLineDirect(message); + return new OutputSection(_buffer, name, _formatter); } - public IDisposable BeginSection(string name) + private sealed class OutputSection : IDisposable { - return _outputWriter.BeginSection(name); + private readonly IModuleOutputBuffer _buffer; + private readonly string _name; + private readonly IBuildSystemFormatter _formatter; + + public OutputSection(IModuleOutputBuffer buffer, string name, IBuildSystemFormatter formatter) + { + _buffer = buffer; + _name = name; + _formatter = formatter; + + var startCommand = formatter.GetStartBlockCommand(name); + if (startCommand != null) + { + _buffer.WriteLine(startCommand); + } + } + + public void Dispose() + { + var endCommand = _formatter.GetEndBlockCommand(_name); + if (endCommand != null) + { + _buffer.WriteLine(endCommand); + } + } } -} \ No newline at end of file +} diff --git a/src/ModularPipelines/Console/ConsoleCoordinator.cs b/src/ModularPipelines/Console/ConsoleCoordinator.cs new file mode 100644 index 0000000000..df60f2ac2a --- /dev/null +++ b/src/ModularPipelines/Console/ConsoleCoordinator.cs @@ -0,0 +1,402 @@ +using System.Collections.Concurrent; +using System.Diagnostics.CodeAnalysis; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using ModularPipelines.Engine; +using ModularPipelines.Helpers; +using ModularPipelines.Models; +using ModularPipelines.Modules; +using ModularPipelines.Options; +using Spectre.Console; + +namespace ModularPipelines.Console; + +/// +/// Central implementation that owns all console output. +/// +/// +/// +/// Architecture: This coordinator is the single point of control for all console output. +/// It intercepts Console.Out/Error to catch rogue writes and routes them to appropriate buffers. +/// Spectre.Console is configured to write directly to the real console for progress rendering. +/// +/// +/// Thread Safety: This class is thread-safe. All methods can be called +/// concurrently from multiple threads. +/// +/// +/// IProgressDisplay: Implements IProgressDisplay to integrate with existing notification +/// system. The ProgressPrinter forwards notifications here, which are delegated to the active session. +/// +/// +[ExcludeFromCodeCoverage] +internal class ConsoleCoordinator : IConsoleCoordinator, IProgressDisplay +{ + private readonly IBuildSystemFormatterProvider _formatterProvider; + private readonly IResultsPrinter _resultsPrinter; + private readonly ISecretObfuscator _secretObfuscator; + private readonly IOptions _options; + private readonly ILoggerFactory _loggerFactory; + + // Console state + private TextWriter? _originalConsoleOut; + private TextWriter? _originalConsoleError; + private IAnsiConsole? _originalAnsiConsole; + private CoordinatedTextWriter? _coordinatedOut; + private CoordinatedTextWriter? _coordinatedError; + + // Phase management + private volatile bool _isProgressActive; + private readonly object _phaseLock = new(); + private bool _isInstalled; + private IProgressSession? _activeSession; + + // Module buffers + private readonly ConcurrentDictionary _moduleBuffers = new(); + private readonly ModuleOutputBuffer _unattributedBuffer; + + // Deferred exceptions + private readonly ConcurrentQueue _deferredExceptions = new(); + + // Logger for output + private ILogger? _outputLogger; + + public ConsoleCoordinator( + IBuildSystemFormatterProvider formatterProvider, + IResultsPrinter resultsPrinter, + ISecretObfuscator secretObfuscator, + IOptions options, + ILoggerFactory loggerFactory) + { + _formatterProvider = formatterProvider; + _resultsPrinter = resultsPrinter; + _secretObfuscator = secretObfuscator; + _options = options; + _loggerFactory = loggerFactory; + _unattributedBuffer = new ModuleOutputBuffer("Pipeline", typeof(void)); + } + + /// + public void Install() + { + lock (_phaseLock) + { + if (_isInstalled) + { + throw new InvalidOperationException("ConsoleCoordinator is already installed."); + } + + // Save original streams before any modifications + var originalOut = System.Console.Out; + var originalError = System.Console.Error; + var originalAnsi = AnsiConsole.Console; + + try + { + _originalConsoleOut = originalOut; + _originalConsoleError = originalError; + _originalAnsiConsole = originalAnsi; + + // Configure Spectre.Console to use the REAL console directly + // This bypasses our interception for progress rendering + AnsiConsole.Console = AnsiConsole.Create(new AnsiConsoleSettings + { + Out = new AnsiConsoleOutput(_originalConsoleOut) + }); + + // Create logger for structured output during flush + _outputLogger = _loggerFactory.CreateLogger("ModularPipelines.Output"); + + // Install our intercepting writers + _coordinatedOut = new CoordinatedTextWriter( + this, + _originalConsoleOut, + () => _isProgressActive, + _secretObfuscator); + + _coordinatedError = new CoordinatedTextWriter( + this, + _originalConsoleError, + () => _isProgressActive, + _secretObfuscator); + + System.Console.SetOut(_coordinatedOut); + System.Console.SetError(_coordinatedError); + + _isInstalled = true; + } + catch + { + // Restore original streams on failure + System.Console.SetOut(originalOut); + System.Console.SetError(originalError); + AnsiConsole.Console = originalAnsi; + + _originalConsoleOut = null; + _originalConsoleError = null; + _originalAnsiConsole = null; + _coordinatedOut = null; + _coordinatedError = null; + + throw; + } + } + } + + /// + public void Uninstall() + { + lock (_phaseLock) + { + if (!_isInstalled) + { + return; + } + + // Flush any remaining buffered content + _coordinatedOut?.Flush(); + _coordinatedError?.Flush(); + + // Restore original streams + if (_originalConsoleOut != null) + { + System.Console.SetOut(_originalConsoleOut); + } + + if (_originalConsoleError != null) + { + System.Console.SetError(_originalConsoleError); + } + + if (_originalAnsiConsole != null) + { + AnsiConsole.Console = _originalAnsiConsole; + } + + _originalConsoleOut = null; + _originalConsoleError = null; + _originalAnsiConsole = null; + _coordinatedOut = null; + _coordinatedError = null; + _isInstalled = false; + } + } + + /// + public async Task BeginProgressAsync( + OrganizedModules modules, + CancellationToken cancellationToken) + { + if (!_options.Value.ShowProgressInConsole) + { + // Return a no-op session if progress is disabled + _activeSession = new NoOpProgressSession(); + return _activeSession; + } + + lock (_phaseLock) + { + if (_isProgressActive) + { + throw new InvalidOperationException("Progress session is already active."); + } + + _isProgressActive = true; + } + + var session = new ProgressSession( + this, + modules, + _options, + cancellationToken); + + _activeSession = session; + + // Start the progress display + session.Start(); + + return session; + } + + /// + /// Ends the progress phase. Called by ProgressSession.DisposeAsync(). + /// + internal void EndProgressPhase() + { + lock (_phaseLock) + { + _isProgressActive = false; + _activeSession = null; + } + } + + /// + public IModuleOutputBuffer GetModuleBuffer(Type moduleType) + { + return _moduleBuffers.GetOrAdd(moduleType, t => new ModuleOutputBuffer(t)); + } + + /// + public IModuleOutputBuffer GetUnattributedBuffer() => _unattributedBuffer; + + /// + public void FlushModuleOutput() + { + if (_originalConsoleOut == null) + { + throw new InvalidOperationException("ConsoleCoordinator is not installed."); + } + + var formatter = _formatterProvider.GetFormatter(); + + // Flush unattributed output first (if any) + if (_unattributedBuffer.HasOutput) + { + var unattributedLogger = _outputLogger ?? _loggerFactory.CreateLogger("ModularPipelines.Output"); + _unattributedBuffer.FlushTo(_originalConsoleOut, formatter, unattributedLogger); + } + + // Flush module buffers in completion order + var orderedBuffers = _moduleBuffers.Values + .Where(b => b.HasOutput) + .OrderBy(b => b.CompletedAtUtc ?? DateTime.MaxValue) + .ToList(); + + foreach (var buffer in orderedBuffers) + { + // Create logger with the correct module category for proper log replay + var moduleLogger = _loggerFactory.CreateLogger(buffer.ModuleType); + buffer.FlushTo(_originalConsoleOut, formatter, moduleLogger); + } + + // Clear buffers after flush to release memory + // This prevents accumulation in long-running pipelines + _moduleBuffers.Clear(); + } + + /// + public void WriteResults(PipelineSummary summary) + { + // Results printer uses AnsiConsole which we've configured + // to write directly to the real console + _resultsPrinter.PrintResults(summary); + } + + /// + public void AddDeferredException(string message) + { + _deferredExceptions.Enqueue(message); + } + + /// + public void WriteExceptions() + { + if (_deferredExceptions.IsEmpty) + { + return; + } + + var messages = new List(); + while (_deferredExceptions.TryDequeue(out var message)) + { + messages.Add(message); + } + + if (messages.Count == 0) + { + return; + } + + // Write using AnsiConsole (goes to real console) + AnsiConsole.WriteLine(); + AnsiConsole.MarkupLine("[bold yellow]\u26a0[/] [bold red]Deferred Exceptions[/]"); + AnsiConsole.WriteLine(); + + foreach (var message in messages) + { + AnsiConsole.WriteLine(message); + } + + AnsiConsole.WriteLine(); + } + + /// + public async ValueTask DisposeAsync() + { + // Flush any buffered module output before uninstalling + // This ensures logs are written even when pipeline isn't executed (e.g., in tests) + if (_isInstalled) + { + try + { + FlushModuleOutput(); + } + catch (InvalidOperationException) + { + // Ignore if not installed - FlushModuleOutput requires installation + } + } + + Uninstall(); + await Task.CompletedTask; + } + + #region IProgressDisplay Implementation + + /// + /// Implements IProgressDisplay.RunAsync for integration with existing notification system. + /// This is called by ProgressPrinter when the old code path is used. + /// + async Task IProgressDisplay.RunAsync(OrganizedModules organizedModules, CancellationToken cancellationToken) + { + // Install if not already installed (for backward compatibility) + if (!_isInstalled) + { + Install(); + } + + await using var session = await BeginProgressAsync(organizedModules, cancellationToken).ConfigureAwait(false); + + // Wait for cancellation - the session runs until disposed + try + { + await Task.Delay(Timeout.Infinite, cancellationToken).ConfigureAwait(false); + } + catch (OperationCanceledException) + { + // Expected - progress ends when cancelled + } + } + + /// + void IProgressDisplay.OnModuleStarted(ModuleState moduleState, TimeSpan estimatedDuration) + { + _activeSession?.OnModuleStarted(moduleState, estimatedDuration); + } + + /// + void IProgressDisplay.OnModuleCompleted(ModuleState moduleState, bool isSuccessful) + { + _activeSession?.OnModuleCompleted(moduleState, isSuccessful); + } + + /// + void IProgressDisplay.OnModuleSkipped(ModuleState moduleState) + { + _activeSession?.OnModuleSkipped(moduleState); + } + + /// + void IProgressDisplay.OnSubModuleCreated(IModule parentModule, SubModuleBase subModule, TimeSpan estimatedDuration) + { + _activeSession?.OnSubModuleCreated(parentModule, subModule, estimatedDuration); + } + + /// + void IProgressDisplay.OnSubModuleCompleted(SubModuleBase subModule, bool isSuccessful) + { + _activeSession?.OnSubModuleCompleted(subModule, isSuccessful); + } + + #endregion +} diff --git a/src/ModularPipelines/Console/CoordinatedTextWriter.cs b/src/ModularPipelines/Console/CoordinatedTextWriter.cs new file mode 100644 index 0000000000..944ee646e0 --- /dev/null +++ b/src/ModularPipelines/Console/CoordinatedTextWriter.cs @@ -0,0 +1,194 @@ +using System.Diagnostics.CodeAnalysis; +using System.Text; +using ModularPipelines.Engine; +using ModularPipelines.Logging; + +namespace ModularPipelines.Console; + +/// +/// Intercepts Console.Out/Error writes and routes them through the coordinator. +/// +/// +/// +/// Purpose: This writer replaces Console.Out/Error to catch all direct +/// console writes. During progress phase, writes are buffered per-module. +/// After progress ends, writes pass through directly. +/// +/// +/// Module Detection: Uses (AsyncLocal) +/// to detect which module (if any) is currently executing. This allows Console.WriteLine +/// calls inside modules to be attributed to the correct module's output buffer. +/// +/// +/// Thread Safety: This class is thread-safe. All operations are either +/// read-only or delegated to thread-safe components. +/// +/// +[ExcludeFromCodeCoverage] +internal class CoordinatedTextWriter : TextWriter +{ + private readonly IConsoleCoordinator _coordinator; + private readonly TextWriter _realConsole; + private readonly Func _isProgressActive; + private readonly ISecretObfuscator _secretObfuscator; + private readonly StringBuilder _lineBuffer = new(); + private readonly object _lineBufferLock = new(); + + /// + /// Initializes a new coordinated text writer. + /// + /// The console coordinator. + /// The real console to write to when not buffering. + /// Function that returns whether progress is active. + /// Obfuscator for secrets in output. + public CoordinatedTextWriter( + IConsoleCoordinator coordinator, + TextWriter realConsole, + Func isProgressActive, + ISecretObfuscator secretObfuscator) + { + _coordinator = coordinator; + _realConsole = realConsole; + _isProgressActive = isProgressActive; + _secretObfuscator = secretObfuscator; + } + + /// + public override Encoding Encoding => _realConsole.Encoding; + + /// + public override void WriteLine(string? value) + { + var message = value ?? string.Empty; + var obfuscated = _secretObfuscator.Obfuscate(message, null); + + if (!_isProgressActive()) + { + // Progress not running - write directly + _realConsole.WriteLine(obfuscated); + return; + } + + // Progress is active - buffer the output + RouteToBuffer(obfuscated); + } + + /// + public override void WriteLine() + { + WriteLine(string.Empty); + } + + /// + public override void Write(string? value) + { + if (value == null) + { + return; + } + + var obfuscated = _secretObfuscator.Obfuscate(value, null); + + if (!_isProgressActive()) + { + // Progress not running - write directly + _realConsole.Write(obfuscated); + return; + } + + // Progress is active - accumulate until newline + lock (_lineBufferLock) + { + foreach (var c in obfuscated) + { + if (c == '\n') + { + // Flush the line + var line = _lineBuffer.ToString().TrimEnd('\r'); + _lineBuffer.Clear(); + RouteToBuffer(line); + } + else + { + _lineBuffer.Append(c); + } + } + } + } + + /// + public override void Write(char value) + { + Write(value.ToString()); + } + + /// + public override void Write(char[] buffer, int index, int count) + { + Write(new string(buffer, index, count)); + } + + /// + /// Routes a message to the appropriate buffer based on current module context. + /// + private void RouteToBuffer(string message) + { + var currentModule = ModuleLogger.CurrentModuleType.Value; + + if (currentModule != null) + { + // Inside a module - route to that module's buffer + var buffer = _coordinator.GetModuleBuffer(currentModule); + buffer.WriteLine(message); + } + else + { + // Outside any module - route to unattributed buffer + _coordinator.GetUnattributedBuffer().WriteLine(message); + } + } + + /// + public override void Flush() + { + // Flush any partial line in the buffer + lock (_lineBufferLock) + { + if (_lineBuffer.Length > 0) + { + var line = _lineBuffer.ToString(); + _lineBuffer.Clear(); + + if (_isProgressActive()) + { + RouteToBuffer(line); + } + else + { + _realConsole.Write(line); + } + } + } + + // Always flush real console (needed for Spectre.Console internals) + _realConsole.Flush(); + } + + /// + public override Task FlushAsync() + { + Flush(); + return _realConsole.FlushAsync(); + } + + /// + protected override void Dispose(bool disposing) + { + if (disposing) + { + Flush(); + } + + base.Dispose(disposing); + } +} diff --git a/src/ModularPipelines/Console/IConsoleCoordinator.cs b/src/ModularPipelines/Console/IConsoleCoordinator.cs new file mode 100644 index 0000000000..ab3153c411 --- /dev/null +++ b/src/ModularPipelines/Console/IConsoleCoordinator.cs @@ -0,0 +1,104 @@ +using ModularPipelines.Models; + +namespace ModularPipelines.Console; + +/// +/// Owns all console output for the pipeline lifecycle. +/// Provides buffering, phase management, and module-aware output routing. +/// +/// +/// +/// Architecture: This coordinator is the single point of control for all console output. +/// It intercepts Console.Out/Error to catch rogue writes and routes them to appropriate buffers. +/// +/// +/// Lifecycle: +/// 1. Install() - Intercepts Console.Out/Error +/// 2. BeginProgressAsync() - Starts progress display phase +/// 3. [Pipeline executes] - All output buffered per-module +/// 4. ProgressSession disposed - Ends progress phase +/// 5. FlushModuleOutput() - Writes buffered output in order +/// 6. WriteResults() - Prints results table +/// 7. WriteExceptions() - Prints deferred exceptions +/// 8. Uninstall() - Restores original console +/// +/// +/// Thread Safety: This class is thread-safe. All methods can be called +/// concurrently from multiple threads. +/// +/// +internal interface IConsoleCoordinator : IAsyncDisposable +{ + /// + /// Installs the coordinator, intercepting Console.Out/Error. + /// Must be called before pipeline execution begins. + /// + /// + /// After installation: + /// - Console.Out/Error are replaced with coordinated writers + /// - AnsiConsole is configured to write directly to the real console + /// - All Console.Write* calls are intercepted and routed appropriately + /// + /// Thrown if already installed. + void Install(); + + /// + /// Begins the progress display phase. + /// + /// + /// During the progress phase: + /// - Only Spectre.Console (via AnsiConsole) writes directly to console + /// - All other output is buffered per-module using AsyncLocal context + /// - The phase ends when the returned session is disposed + /// + /// The organized modules for progress tracking. + /// Cancellation token. + /// A progress session that must be disposed to end the progress phase. + /// Thrown if progress is already active. + Task BeginProgressAsync(OrganizedModules modules, CancellationToken cancellationToken); + + /// + /// Gets the output buffer for a specific module. + /// Creates the buffer if it doesn't exist. + /// + /// The module type. + /// The module's output buffer. + IModuleOutputBuffer GetModuleBuffer(Type moduleType); + + /// + /// Gets the buffer for output that occurs outside any module context. + /// + /// The unattributed output buffer. + IModuleOutputBuffer GetUnattributedBuffer(); + + /// + /// Flushes all module output in completion order. + /// Should be called after progress phase ends. + /// + /// Thrown if not installed. + void FlushModuleOutput(); + + /// + /// Writes the pipeline results table. + /// + /// The pipeline summary to display. + void WriteResults(PipelineSummary summary); + + /// + /// Adds an exception message for deferred output. + /// + /// The formatted exception message. + void AddDeferredException(string message); + + /// + /// Writes any deferred exceptions. + /// Should be called after WriteResults(). + /// + void WriteExceptions(); + + /// + /// Restores original Console.Out/Error. + /// Safe to call multiple times. + /// + void Uninstall(); +} diff --git a/src/ModularPipelines/Console/IModuleOutputBuffer.cs b/src/ModularPipelines/Console/IModuleOutputBuffer.cs new file mode 100644 index 0000000000..cf7f97a582 --- /dev/null +++ b/src/ModularPipelines/Console/IModuleOutputBuffer.cs @@ -0,0 +1,83 @@ +using Microsoft.Extensions.Logging; +using ModularPipelines.Engine; + +namespace ModularPipelines.Console; + +/// +/// Buffers all output for a single module. +/// +/// +/// +/// Purpose: Consolidates all module output into a single buffer: +/// logger output, Console.WriteLine interceptions, and explicit writes. +/// +/// +/// Thread Safety: All methods are thread-safe and can be called concurrently. +/// +/// +/// Flush Ordering: Buffers are flushed in completion order (by CompletedAtUtc) +/// to maintain logical output sequence. +/// +/// +internal interface IModuleOutputBuffer +{ + /// + /// Gets the module type this buffer belongs to. + /// + Type ModuleType { get; } + + /// + /// Gets when the module completed (for ordering during flush). + /// Null if not yet completed. + /// + DateTime? CompletedAtUtc { get; } + + /// + /// Records completion time for flush ordering. + /// Called when the module finishes execution. + /// + void MarkCompleted(); + + /// + /// Adds a plain string line to the buffer. + /// Used for Console.WriteLine interceptions. + /// + /// The message to buffer. + void WriteLine(string message); + + /// + /// Adds a structured log event to the buffer. + /// Used for ILogger calls. + /// + /// Log level. + /// Event identifier. + /// Log state object. + /// Optional exception. + /// Formatter function. + void AddLogEvent( + LogLevel level, + EventId eventId, + object state, + Exception? exception, + Func formatter); + + /// + /// Sets the exception if the module failed. + /// Used for section header formatting. + /// + /// The exception that caused failure. + void SetException(Exception exception); + + /// + /// Gets whether there is any output to flush. + /// + bool HasOutput { get; } + + /// + /// Flushes all buffered output to the console with CI formatting. + /// + /// The console to write to. + /// The CI-specific formatter for log groups. + /// The logger for structured log output. + void FlushTo(TextWriter console, IBuildSystemFormatter formatter, ILogger logger); +} diff --git a/src/ModularPipelines/Console/IProgressSession.cs b/src/ModularPipelines/Console/IProgressSession.cs new file mode 100644 index 0000000000..1e8bd730ff --- /dev/null +++ b/src/ModularPipelines/Console/IProgressSession.cs @@ -0,0 +1,65 @@ +using ModularPipelines.Engine; + +namespace ModularPipelines.Console; + +/// +/// Represents an active progress display session. +/// +/// +/// +/// Lifecycle: The session is active from creation until disposal. +/// During this time, the progress display is rendered and module status updates +/// are reflected in the progress bars. +/// +/// +/// Critical: The session MUST be disposed before flushing module output. +/// Disposing the session ends the progress phase and ensures the progress display +/// has fully stopped before any other console output occurs. +/// +/// +/// Thread Safety: All methods are thread-safe and can be called concurrently +/// from notification handlers running on different threads. +/// +/// +internal interface IProgressSession : IAsyncDisposable +{ + /// + /// Called when a module starts execution. + /// Adds a progress bar for the module. + /// + /// The module state. + /// Estimated duration for progress animation. + void OnModuleStarted(ModuleState state, TimeSpan estimatedDuration); + + /// + /// Called when a module completes execution. + /// Updates the progress bar to show completion status. + /// + /// The module state. + /// Whether the module completed successfully. + void OnModuleCompleted(ModuleState state, bool isSuccessful); + + /// + /// Called when a module is skipped. + /// Updates the progress bar to show skipped status. + /// + /// The module state. + void OnModuleSkipped(ModuleState state); + + /// + /// Called when a sub-module is created. + /// Adds a nested progress bar for the sub-module. + /// + /// The parent module. + /// The sub-module. + /// Estimated duration for progress animation. + void OnSubModuleCreated(Modules.IModule parentModule, Modules.SubModuleBase subModule, TimeSpan estimatedDuration); + + /// + /// Called when a sub-module completes. + /// Updates the sub-module progress bar to show completion status. + /// + /// The sub-module. + /// Whether the sub-module completed successfully. + void OnSubModuleCompleted(Modules.SubModuleBase subModule, bool isSuccessful); +} diff --git a/src/ModularPipelines/Console/ModuleOutputBuffer.cs b/src/ModularPipelines/Console/ModuleOutputBuffer.cs new file mode 100644 index 0000000000..9d1b6729f5 --- /dev/null +++ b/src/ModularPipelines/Console/ModuleOutputBuffer.cs @@ -0,0 +1,244 @@ +using System.Diagnostics.CodeAnalysis; +using Microsoft.Extensions.Logging; +using ModularPipelines.Engine; + +namespace ModularPipelines.Console; + +/// +/// Buffers all output for a single module. +/// +/// +/// +/// Thread Safety: This class is thread-safe. All public methods use locking +/// to ensure safe concurrent access from multiple threads. +/// +/// +/// Buffer Contents: The buffer holds both plain string output (from Console.WriteLine +/// interceptions) and structured log events (from ILogger calls). Both are stored in +/// insertion order and flushed together. +/// +/// +[ExcludeFromCodeCoverage] +internal class ModuleOutputBuffer : IModuleOutputBuffer +{ + private readonly List _outputs = new(); + private readonly object _lock = new(); + private readonly string _moduleName; + private readonly DateTime _startTimeUtc; + private Exception? _exception; + + /// + public Type ModuleType { get; } + + /// + public DateTime? CompletedAtUtc { get; private set; } + + /// + /// Initializes a new buffer for the specified module type. + /// + /// The module type. + public ModuleOutputBuffer(Type moduleType) + { + ModuleType = moduleType; + _moduleName = moduleType.Name; + _startTimeUtc = DateTime.UtcNow; + } + + /// + /// Initializes a buffer for unattributed output (not from any module). + /// + /// Display name for the buffer. + /// Placeholder type. + internal ModuleOutputBuffer(string name, Type moduleType) + { + ModuleType = moduleType; + _moduleName = name; + _startTimeUtc = DateTime.UtcNow; + } + + /// + public void WriteLine(string message) + { + lock (_lock) + { + _outputs.Add(BufferedOutput.FromString(message)); + } + } + + /// + public void AddLogEvent( + LogLevel level, + EventId eventId, + object state, + Exception? exception, + Func formatter) + { + lock (_lock) + { + _outputs.Add(BufferedOutput.FromLogEvent(level, eventId, state, exception, formatter)); + } + } + + /// + public void MarkCompleted() + { + lock (_lock) + { + CompletedAtUtc ??= DateTime.UtcNow; + } + } + + /// + public void SetException(Exception exception) + { + lock (_lock) + { + _exception = exception; + } + } + + /// + public bool HasOutput + { + get + { + lock (_lock) + { + return _outputs.Count > 0; + } + } + } + + /// + public void FlushTo(TextWriter console, IBuildSystemFormatter formatter, ILogger logger) + { + List outputs; + Exception? exception; + + lock (_lock) + { + if (_outputs.Count == 0) + { + return; + } + + outputs = new List(_outputs); + _outputs.Clear(); + exception = _exception; + } + + // Write section header (CI group start) + var header = FormatHeader(exception); + var startCommand = formatter.GetStartBlockCommand(header); + if (startCommand != null) + { + console.WriteLine(startCommand); + } + + // Write all buffered output + // Note: Log events are already written to loggers during Log() calls, + // so we just format and output them here for console display + foreach (var output in outputs) + { + if (output.IsString) + { + console.WriteLine(output.StringValue); + } + else if (output.LogEvent.HasValue) + { + var logEvent = output.LogEvent.Value; + // Format the log event for console output (logs already went to providers) + var formatted = logEvent.Formatter(logEvent.State, logEvent.Exception); + if (!string.IsNullOrEmpty(formatted)) + { + console.WriteLine(formatted); + } + } + } + + // Write section footer (CI group end) + var endCommand = formatter.GetEndBlockCommand(header); + if (endCommand != null) + { + console.WriteLine(endCommand); + } + } + + private string FormatHeader(Exception? exception) + { + var duration = (CompletedAtUtc ?? DateTime.UtcNow) - _startTimeUtc; + var durationStr = duration.TotalSeconds >= 60 + ? $"{duration.TotalMinutes:F1}m" + : $"{duration.TotalSeconds:F1}s"; + + if (exception != null) + { + return $"{_moduleName} \u2717 ({durationStr}) - {exception.GetType().Name}"; + } + + return $"{_moduleName} \u2713 ({durationStr})"; + } +} + +/// +/// Represents either a string or a structured log event in the buffer. +/// +internal readonly struct BufferedOutput +{ + /// + /// Gets the string value if this is a string output. + /// + public string? StringValue { get; private init; } + + /// + /// Gets the log event if this is a structured log event. + /// + public LogEventData? LogEvent { get; private init; } + + /// + /// Gets whether this is a string output. + /// + public bool IsString => StringValue != null; + + /// + /// Creates a buffered output from a string. + /// + public static BufferedOutput FromString(string value) => new() { StringValue = value }; + + /// + /// Creates a buffered output from a log event. + /// + public static BufferedOutput FromLogEvent( + LogLevel level, + EventId eventId, + object state, + Exception? exception, + Func formatter) + => new() { LogEvent = new LogEventData(level, eventId, state, exception, formatter) }; +} + +/// +/// Holds structured log event data for deferred output. +/// +internal readonly struct LogEventData +{ + public LogLevel Level { get; } + public EventId EventId { get; } + public object State { get; } + public Exception? Exception { get; } + public Func Formatter { get; } + + public LogEventData( + LogLevel level, + EventId eventId, + object state, + Exception? exception, + Func formatter) + { + Level = level; + EventId = eventId; + State = state; + Exception = exception; + Formatter = formatter; + } +} diff --git a/src/ModularPipelines/Console/ProgressSession.cs b/src/ModularPipelines/Console/ProgressSession.cs new file mode 100644 index 0000000000..c22d8e49ad --- /dev/null +++ b/src/ModularPipelines/Console/ProgressSession.cs @@ -0,0 +1,374 @@ +using System.Collections.Concurrent; +using System.Diagnostics.CodeAnalysis; +using Microsoft.Extensions.Options; +using ModularPipelines.Engine; +using ModularPipelines.Models; +using ModularPipelines.Modules; +using ModularPipelines.Options; +using Spectre.Console; + +namespace ModularPipelines.Console; + +/// +/// Manages an active progress display session using Spectre.Console. +/// +/// +/// +/// Lifecycle: The session is active from Start() until DisposeAsync(). +/// During this time, the progress display renders and module status updates +/// are reflected in the progress bars. +/// +/// +/// Critical: DisposeAsync() MUST be called before flushing module output. +/// It ensures the progress display has fully stopped before any other console output. +/// +/// +/// Thread Safety: All methods are thread-safe and can be called concurrently. +/// +/// +[ExcludeFromCodeCoverage] +internal class ProgressSession : IProgressSession +{ + private readonly ConsoleCoordinator _coordinator; + private readonly OrganizedModules _modules; + private readonly IOptions _options; + private readonly CancellationToken _cancellationToken; + + private readonly ConcurrentDictionary _moduleTasks = new(); + private readonly ConcurrentDictionary _subModuleTasks = new(); + private readonly object _progressLock = new(); + + private ProgressContext? _progressContext; + private ProgressTask? _totalTask; + private readonly TaskCompletionSource _progressCompleted = new(); + private int _totalModuleCount; + private int _completedModuleCount; + + public ProgressSession( + ConsoleCoordinator coordinator, + OrganizedModules modules, + IOptions options, + CancellationToken cancellationToken) + { + _coordinator = coordinator; + _modules = modules; + _options = options; + _cancellationToken = cancellationToken; + } + + /// + /// Starts the progress display loop. + /// + public void Start() + { + _totalModuleCount = _modules.RunnableModules.Count; + + // Fire and forget - progress runs until disposed + _ = RunProgressLoopAsync(); + } + + private async Task RunProgressLoopAsync() + { + try + { + await AnsiConsole.Progress() + .AutoRefresh(true) + .AutoClear(false) + .HideCompleted(false) + .Columns( + new TaskDescriptionColumn(), + new ProgressBarColumn(), + new PercentageColumn(), + new ElapsedTimeColumn(), + new RemainingTimeColumn(), + new SpinnerColumn()) + .StartAsync(async ctx => + { + _progressContext = ctx; + _totalTask = ctx.AddTask("[green]Total[/]"); + + // Register ignored modules immediately + RegisterIgnoredModules(ctx); + + // Keep alive until all modules complete or cancellation + while (!ctx.IsFinished && !_cancellationToken.IsCancellationRequested) + { + await Task.Delay(100, CancellationToken.None).ConfigureAwait(false); + } + }); + } + catch (OperationCanceledException) + { + // Expected on cancellation + } + catch (Exception) + { + // Suppress exceptions from progress display + } + finally + { + _progressCompleted.TrySetResult(); + } + } + + private void RegisterIgnoredModules(ProgressContext ctx) + { + foreach (var ignored in _modules.IgnoredModules) + { + var name = ignored.Module.GetType().Name; + ctx.AddTask($"[yellow][[Ignored]] {name}[/]").StopTask(); + } + } + + /// + public void OnModuleStarted(ModuleState state, TimeSpan estimatedDuration) + { + if (_progressContext == null) + { + return; + } + + lock (_progressLock) + { + var name = state.ModuleType.Name; + var task = _progressContext.AddTask(name, autoStart: true); + _moduleTasks[state.Module] = task; + + // Start background ticker for progress animation + StartProgressTicker(task, estimatedDuration); + } + } + + /// + public void OnModuleCompleted(ModuleState state, bool isSuccessful) + { + if (_progressContext == null) + { + return; + } + + lock (_progressLock) + { + if (_moduleTasks.TryGetValue(state.Module, out var task)) + { + // Complete the progress bar + if (!task.IsFinished) + { + task.Increment(100 - task.Value); + + var name = state.ModuleType.Name; + task.Description = isSuccessful + ? $"[green]{name}[/]" + : $"[red][[Failed]] {name}[/]"; + + task.StopTask(); + } + + // Update total progress + _completedModuleCount++; + var increment = 100.0 / _totalModuleCount; + _totalTask?.Increment(increment); + + // Mark buffer as completed for ordering + _coordinator.GetModuleBuffer(state.ModuleType).MarkCompleted(); + + // Check if all done + if (_completedModuleCount >= _totalModuleCount) + { + _totalTask?.StopTask(); + } + } + } + } + + /// + public void OnModuleSkipped(ModuleState state) + { + if (_progressContext == null) + { + return; + } + + lock (_progressLock) + { + var name = state.ModuleType.Name; + + if (_moduleTasks.TryGetValue(state.Module, out var task)) + { + task.Description = $"[yellow][[Skipped]] {name}[/]"; + if (!task.IsFinished) + { + task.StopTask(); + } + } + else + { + var newTask = _progressContext.AddTask($"[yellow][[Skipped]] {name}[/]"); + newTask.StopTask(); + } + + _completedModuleCount++; + var increment = 100.0 / _totalModuleCount; + _totalTask?.Increment(increment); + + _coordinator.GetModuleBuffer(state.ModuleType).MarkCompleted(); + + if (_completedModuleCount >= _totalModuleCount) + { + _totalTask?.StopTask(); + } + } + } + + /// + public void OnSubModuleCreated(IModule parentModule, SubModuleBase subModule, TimeSpan estimatedDuration) + { + if (_progressContext == null) + { + return; + } + + lock (_progressLock) + { + if (!_moduleTasks.TryGetValue(parentModule, out var parentTask)) + { + return; + } + + var task = _progressContext.AddTaskAfter( + $" - {subModule.Name}", + new ProgressTaskSettings { AutoStart = true }, + parentTask); + + _subModuleTasks[subModule] = task; + + // Start background ticker for progress animation + StartProgressTicker(task, estimatedDuration); + } + } + + /// + public void OnSubModuleCompleted(SubModuleBase subModule, bool isSuccessful) + { + if (_progressContext == null) + { + return; + } + + lock (_progressLock) + { + if (_subModuleTasks.TryGetValue(subModule, out var task)) + { + if (!task.IsFinished) + { + if (isSuccessful) + { + task.Increment(100 - task.Value); + } + + task.Description = isSuccessful + ? $"[green] - {subModule.Name}[/]" + : $"[red][[Failed]] - {subModule.Name}[/]"; + + task.StopTask(); + } + } + } + } + + private void StartProgressTicker(ProgressTask task, TimeSpan estimatedDuration) + { + _ = Task.Run(async () => + { + try + { + // Calculate tick rate based on estimate + var totalTicks = 95.0; // Go to 95%, completion fills the rest + var seconds = estimatedDuration.TotalSeconds > 0 ? estimatedDuration.TotalSeconds : 10.0; + var ticksPerSecond = Math.Clamp(totalTicks / seconds, 0.5, 20.0); + + while (task is { IsFinished: false, Value: < 95 }) + { + await Task.Delay(1000, CancellationToken.None).ConfigureAwait(false); + + lock (_progressLock) + { + if (!task.IsFinished && task.Value < 95) + { + task.Increment(ticksPerSecond); + } + } + } + } + catch + { + // Ignore - progress ticking is best-effort + } + }, CancellationToken.None); + } + + /// + public async ValueTask DisposeAsync() + { + // Signal all tasks to stop + lock (_progressLock) + { + _totalTask?.StopTask(); + + foreach (var task in _moduleTasks.Values) + { + if (!task.IsFinished) + { + task.StopTask(); + } + } + + foreach (var task in _subModuleTasks.Values) + { + if (!task.IsFinished) + { + task.StopTask(); + } + } + } + + // Wait for progress loop to finish (with timeout) + var timeoutTask = Task.Delay(TimeSpan.FromSeconds(5)); + await Task.WhenAny(_progressCompleted.Task, timeoutTask).ConfigureAwait(false); + + // NOW it's safe to end the progress phase + _coordinator.EndProgressPhase(); + } +} + +/// +/// No-op progress session when progress display is disabled. +/// +[ExcludeFromCodeCoverage] +internal class NoOpProgressSession : IProgressSession +{ + public void OnModuleStarted(ModuleState state, TimeSpan estimatedDuration) + { + } + + public void OnModuleCompleted(ModuleState state, bool isSuccessful) + { + } + + public void OnModuleSkipped(ModuleState state) + { + } + + public void OnSubModuleCreated(IModule parentModule, SubModuleBase subModule, TimeSpan estimatedDuration) + { + } + + public void OnSubModuleCompleted(SubModuleBase subModule, bool isSuccessful) + { + } + + public ValueTask DisposeAsync() + { + return ValueTask.CompletedTask; + } +} diff --git a/src/ModularPipelines/ConsoleWriter.cs b/src/ModularPipelines/ConsoleWriter.cs index ff25f0cfae..a9e7b37448 100644 --- a/src/ModularPipelines/ConsoleWriter.cs +++ b/src/ModularPipelines/ConsoleWriter.cs @@ -16,7 +16,7 @@ public void LogToConsole(string value) { // Fall back to plain console output if markup parsing fails // (e.g., unbalanced or invalid markup characters) - Console.WriteLine(value); + System.Console.WriteLine(value); } } } diff --git a/src/ModularPipelines/DependencyInjection/DependencyInjectionSetup.cs b/src/ModularPipelines/DependencyInjection/DependencyInjectionSetup.cs index 79fd714901..6f0788850d 100644 --- a/src/ModularPipelines/DependencyInjection/DependencyInjectionSetup.cs +++ b/src/ModularPipelines/DependencyInjection/DependencyInjectionSetup.cs @@ -128,8 +128,7 @@ private static void RegisterPipelineContextServices(IServiceCollection services) .AddScoped() .AddScoped() .AddScoped() - .AddScoped() - .AddScoped(); + .AddScoped(); } /// @@ -149,16 +148,17 @@ private static void RegisterLoggingAndConsoleServices(IServiceCollection service .AddSingleton() .AddSingleton() - // Progress display components (SRP extraction from ProgressPrinter) + // Console coordinator - single point of control for all console output + .AddSingleton() + .AddSingleton(sp => sp.GetRequiredService()) + .AddSingleton(sp => sp.GetRequiredService()) + + // Progress display components .AddSingleton() - .AddSingleton() .AddSingleton() .AddSingleton() .AddSingleton(sp => sp.GetRequiredService()) .AddSingleton() - .AddSingleton() - .AddSingleton() - .AddSingleton() .AddSingleton() .AddSingleton(); } @@ -194,7 +194,6 @@ private static void RegisterModuleExecutionServices(IServiceCollection services) .AddSingleton() .AddSingleton() .AddSingleton() - .AddSingleton() .AddSingleton() .AddSingleton() .AddSingleton() diff --git a/src/ModularPipelines/Engine/EngineCancellationToken.cs b/src/ModularPipelines/Engine/EngineCancellationToken.cs index f134bc612c..af505f2f14 100644 --- a/src/ModularPipelines/Engine/EngineCancellationToken.cs +++ b/src/ModularPipelines/Engine/EngineCancellationToken.cs @@ -50,7 +50,7 @@ public EngineCancellationToken(IPrimaryExceptionContainer primaryExceptionContai { _primaryExceptionContainer = primaryExceptionContainer; - Console.CancelKeyPress += (_, args) => + System.Console.CancelKeyPress += (_, args) => { args.Cancel = true; TryCancel(); diff --git a/src/ModularPipelines/Engine/Execution/DependencyWaiter.cs b/src/ModularPipelines/Engine/Execution/DependencyWaiter.cs index 7bde432762..5a59d91d44 100644 --- a/src/ModularPipelines/Engine/Execution/DependencyWaiter.cs +++ b/src/ModularPipelines/Engine/Execution/DependencyWaiter.cs @@ -14,14 +14,10 @@ namespace ModularPipelines.Engine.Execution; internal class DependencyWaiter : IDependencyWaiter { private readonly ISecondaryExceptionContainer _secondaryExceptionContainer; - private readonly IModuleLoggerContainer _loggerContainer; - public DependencyWaiter( - ISecondaryExceptionContainer secondaryExceptionContainer, - IModuleLoggerContainer loggerContainer) + public DependencyWaiter(ISecondaryExceptionContainer secondaryExceptionContainer) { _secondaryExceptionContainer = secondaryExceptionContainer; - _loggerContainer = loggerContainer; } /// @@ -42,7 +38,8 @@ public async Task WaitForDependenciesAsync(ModuleState moduleState, IModuleSched } catch (Exception e) when (moduleState.Module.ModuleRunType == ModuleRunType.AlwaysRun) { - var depLogger = _loggerContainer.GetOrCreateLogger(moduleState.ModuleType, scopedServiceProvider); + var loggerType = typeof(ModuleLogger<>).MakeGenericType(moduleState.ModuleType); + var depLogger = (IModuleLogger)scopedServiceProvider.GetRequiredService(loggerType); _secondaryExceptionContainer.RegisterException(new AlwaysRunPostponedException( $"{dependencyType.Name} threw an exception when {moduleState.ModuleType.Name} was waiting for it as a dependency", e)); diff --git a/src/ModularPipelines/Engine/Execution/ModuleRunner.cs b/src/ModularPipelines/Engine/Execution/ModuleRunner.cs index b5f8d3f58f..cb3e7de152 100644 --- a/src/ModularPipelines/Engine/Execution/ModuleRunner.cs +++ b/src/ModularPipelines/Engine/Execution/ModuleRunner.cs @@ -22,7 +22,6 @@ internal class ModuleRunner : IModuleRunner { private readonly IServiceProvider _serviceProvider; private readonly IModuleExecutionPipeline _executionPipeline; - private readonly IModuleLoggerContainer _loggerContainer; private readonly IPipelineSetupExecutor _pipelineSetupExecutor; private readonly IMediator _mediator; private readonly ISafeModuleEstimatedTimeProvider _moduleEstimatedTimeProvider; @@ -38,7 +37,6 @@ internal class ModuleRunner : IModuleRunner public ModuleRunner( IServiceProvider serviceProvider, IModuleExecutionPipeline executionPipeline, - IModuleLoggerContainer loggerContainer, IPipelineSetupExecutor pipelineSetupExecutor, IMediator mediator, ISafeModuleEstimatedTimeProvider moduleEstimatedTimeProvider, @@ -53,7 +51,6 @@ public ModuleRunner( { _serviceProvider = serviceProvider; _executionPipeline = executionPipeline; - _loggerContainer = loggerContainer; _pipelineSetupExecutor = pipelineSetupExecutor; _mediator = mediator; _moduleEstimatedTimeProvider = moduleEstimatedTimeProvider; @@ -146,7 +143,8 @@ private async Task ExecuteModuleWithPipeline(ModuleState moduleState, IServicePr // Create module-specific context var executionContext = CreateExecutionContext(module, moduleType); - var logger = _loggerContainer.GetOrCreateLogger(moduleType, scopedServiceProvider); + var loggerType = typeof(ModuleLogger<>).MakeGenericType(moduleType); + var logger = (IModuleLogger)scopedServiceProvider.GetRequiredService(loggerType); var moduleContext = new ModuleContext(pipelineContext, module, executionContext, logger); // Start Activity for distributed tracing (Phase 1: alongside AsyncLocal for compatibility) diff --git a/src/ModularPipelines/Engine/Executors/ExecutionOrchestrator.cs b/src/ModularPipelines/Engine/Executors/ExecutionOrchestrator.cs index 7f993ed95d..33982337bc 100644 --- a/src/ModularPipelines/Engine/Executors/ExecutionOrchestrator.cs +++ b/src/ModularPipelines/Engine/Executors/ExecutionOrchestrator.cs @@ -130,7 +130,7 @@ private async Task OnEnd(OrganizedModules organizedModules, Sto _outputCoordinator.PrintResults(pipelineSummary); - await Console.Out.FlushAsync().ConfigureAwait(false); + await System.Console.Out.FlushAsync().ConfigureAwait(false); // Flush any buffered exceptions after the results table has been printed _outputCoordinator.FlushExceptions(); diff --git a/src/ModularPipelines/Engine/Executors/IPrintModuleOutputExecutor.cs b/src/ModularPipelines/Engine/Executors/IPrintModuleOutputExecutor.cs deleted file mode 100644 index 69a4a06e29..0000000000 --- a/src/ModularPipelines/Engine/Executors/IPrintModuleOutputExecutor.cs +++ /dev/null @@ -1,3 +0,0 @@ -namespace ModularPipelines.Engine.Executors; - -internal interface IPrintModuleOutputExecutor : IDisposable; \ No newline at end of file diff --git a/src/ModularPipelines/Engine/Executors/PipelineOutputCoordinator.cs b/src/ModularPipelines/Engine/Executors/PipelineOutputCoordinator.cs index 71eff18172..034dda1be5 100644 --- a/src/ModularPipelines/Engine/Executors/PipelineOutputCoordinator.cs +++ b/src/ModularPipelines/Engine/Executors/PipelineOutputCoordinator.cs @@ -1,3 +1,4 @@ +using ModularPipelines.Console; using ModularPipelines.Helpers; using ModularPipelines.Logging; using ModularPipelines.Models; @@ -8,33 +9,46 @@ namespace ModularPipelines.Engine.Executors; /// Coordinates all pipeline output operations including progress printing, /// module output, results display, and exception flushing. /// +/// +/// +/// Output Phases: +/// 1. Install - Set up console interception +/// 2. Progress - Show progress display while buffering module output +/// 3. Flush - End progress and flush buffered output +/// 4. Results - Print results table +/// 5. Exceptions - Print deferred exceptions +/// +/// internal class PipelineOutputCoordinator : IPipelineOutputCoordinator { private readonly IPrintProgressExecutor _printProgressExecutor; - private readonly IPrintModuleOutputExecutor _printModuleOutputExecutor; private readonly IConsolePrinter _consolePrinter; private readonly IAfterPipelineLogger _afterPipelineLogger; private readonly IExceptionBuffer _exceptionBuffer; + private readonly IConsoleCoordinator _consoleCoordinator; public PipelineOutputCoordinator( IPrintProgressExecutor printProgressExecutor, - IPrintModuleOutputExecutor printModuleOutputExecutor, IConsolePrinter consolePrinter, IAfterPipelineLogger afterPipelineLogger, - IExceptionBuffer exceptionBuffer) + IExceptionBuffer exceptionBuffer, + IConsoleCoordinator consoleCoordinator) { _printProgressExecutor = printProgressExecutor; - _printModuleOutputExecutor = printModuleOutputExecutor; _consolePrinter = consolePrinter; _afterPipelineLogger = afterPipelineLogger; _exceptionBuffer = exceptionBuffer; + _consoleCoordinator = consoleCoordinator; } /// public async Task InitializeAsync() { + // Install console coordination before starting progress + _consoleCoordinator.Install(); + var printProgressExecutor = await _printProgressExecutor.InitializeAsync().ConfigureAwait(false); - return new PipelineOutputScope(_printModuleOutputExecutor, printProgressExecutor); + return new PipelineOutputScope(printProgressExecutor, _consoleCoordinator); } /// @@ -46,6 +60,10 @@ public void PrintResults(PipelineSummary pipelineSummary) /// public void FlushExceptions() { + // Use coordinator for exception output + _consoleCoordinator.WriteExceptions(); + + // Also flush the exception buffer _exceptionBuffer.FlushExceptions(); } @@ -57,21 +75,25 @@ public void WriteLogs() private sealed class PipelineOutputScope : IPipelineOutputScope { - private readonly IPrintModuleOutputExecutor _printModuleOutputExecutor; private readonly IPrintProgressExecutor _printProgressExecutor; + private readonly IConsoleCoordinator _consoleCoordinator; public PipelineOutputScope( - IPrintModuleOutputExecutor printModuleOutputExecutor, - IPrintProgressExecutor printProgressExecutor) + IPrintProgressExecutor printProgressExecutor, + IConsoleCoordinator consoleCoordinator) { - _printModuleOutputExecutor = printModuleOutputExecutor; _printProgressExecutor = printProgressExecutor; + _consoleCoordinator = consoleCoordinator; } public async ValueTask DisposeAsync() { - _printModuleOutputExecutor.Dispose(); + // CRITICAL: Order matters! + // 1. Stop progress display FIRST (ends buffering phase) await _printProgressExecutor.DisposeAsync().ConfigureAwait(false); + + // 2. Flush buffered module output from coordinator + _consoleCoordinator.FlushModuleOutput(); } } } diff --git a/src/ModularPipelines/Engine/Executors/PrintModuleOutputExecutor.cs b/src/ModularPipelines/Engine/Executors/PrintModuleOutputExecutor.cs deleted file mode 100644 index 2200c176ee..0000000000 --- a/src/ModularPipelines/Engine/Executors/PrintModuleOutputExecutor.cs +++ /dev/null @@ -1,20 +0,0 @@ -using System.Diagnostics; -using ModularPipelines.Logging; - -namespace ModularPipelines.Engine.Executors; - -[StackTraceHidden] -internal class PrintModuleOutputExecutor : IPrintModuleOutputExecutor -{ - private readonly IModuleLoggerContainer _moduleLoggerContainer; - - public PrintModuleOutputExecutor(IModuleLoggerContainer moduleLoggerContainer) - { - _moduleLoggerContainer = moduleLoggerContainer; - } - - public void Dispose() - { - _moduleLoggerContainer.FlushAndDisposeAll(); - } -} \ No newline at end of file diff --git a/src/ModularPipelines/Engine/ModuleDisposer.cs b/src/ModularPipelines/Engine/ModuleDisposer.cs index 2e9a7e8ec2..c38eb5cd51 100644 --- a/src/ModularPipelines/Engine/ModuleDisposer.cs +++ b/src/ModularPipelines/Engine/ModuleDisposer.cs @@ -1,33 +1,17 @@ using ModularPipelines.Helpers; -using ModularPipelines.Logging; using ModularPipelines.Modules; namespace ModularPipelines.Engine; internal class ModuleDisposer { - private readonly IModuleLoggerContainer _loggerContainer; - - public ModuleDisposer(IModuleLoggerContainer loggerContainer) - { - _loggerContainer = loggerContainer; - } - public async Task DisposeAsync(ModuleState moduleState) { - await DisposeModuleCore(moduleState.Module, moduleState.ModuleType).ConfigureAwait(false); + await Disposer.DisposeObjectAsync(moduleState.Module).ConfigureAwait(false); } public async Task DisposeAsync(IModule module) - { - await DisposeModuleCore(module, module.GetType()).ConfigureAwait(false); - } - - private async Task DisposeModuleCore(IModule module, Type moduleType) { await Disposer.DisposeObjectAsync(module).ConfigureAwait(false); - - var logger = _loggerContainer.GetLogger(moduleType); - await Disposer.DisposeObjectAsync(logger).ConfigureAwait(false); } -} \ No newline at end of file +} diff --git a/src/ModularPipelines/Helpers/SpectreProgressDisplay.cs b/src/ModularPipelines/Helpers/SpectreProgressDisplay.cs deleted file mode 100644 index 8741357cfa..0000000000 --- a/src/ModularPipelines/Helpers/SpectreProgressDisplay.cs +++ /dev/null @@ -1,278 +0,0 @@ -using System.Collections.Concurrent; -using System.Diagnostics.CodeAnalysis; -using Microsoft.Extensions.Options; -using ModularPipelines.Engine; -using ModularPipelines.Models; -using ModularPipelines.Modules; -using ModularPipelines.Options; -using Spectre.Console; - -namespace ModularPipelines.Helpers; - -/// -/// Spectre.Console implementation of progress display. -/// Handles all console rendering and Spectre.Console-specific operations. -/// -/// -/// -/// Thread Safety: This class is thread-safe. Methods can be called -/// concurrently from multiple threads without external synchronization. -/// -/// -/// Synchronization Strategy: Uses a lock (_progressLock) to protect -/// Spectre.Console ProgressContext operations which are not thread-safe. -/// ConcurrentDictionary is used for progress task lookups to allow lock-free reads -/// from background tasks that tick progress independently. The lock is only held -/// during ProgressContext mutations (AddTask, StopTask, Increment). -/// -/// -/// -[ExcludeFromCodeCoverage] -internal class SpectreProgressDisplay : IProgressDisplay -{ - private readonly IOptions _options; - private readonly IProgressCalculator _progressCalculator; - private readonly ConcurrentDictionary _progressTasks = new(); - private readonly ConcurrentDictionary _moduleStateProgressTasks = new(); - private readonly ConcurrentDictionary _subModuleProgressTasks = new(); - private ProgressContext? _progressContext; - private ProgressTask? _totalProgressTask; - private int _totalModuleCount; - private int _completedModuleCount; - private readonly object _progressLock = new(); - - public SpectreProgressDisplay( - IOptions options, - IProgressCalculator progressCalculator) - { - _options = options; - _progressCalculator = progressCalculator; - } - - public async Task RunAsync(OrganizedModules organizedModules, CancellationToken cancellationToken) - { - if (!_options.Value.ShowProgressInConsole) - { - return; - } - - _totalModuleCount = organizedModules.RunnableModules.Count; - - await AnsiConsole.Progress() - .Columns(new TaskDescriptionColumn(), new ProgressBarColumn(), new PercentageColumn(), - new ElapsedTimeColumn(), new RemainingTimeColumn(), new SpinnerColumn()) - .StartAsync(async progressContext => - { - _progressContext = progressContext; - _totalProgressTask = progressContext.AddTask("[green]Total[/]"); - - // Register ignored modules immediately - RegisterIgnoredModules(organizedModules.IgnoredModules, progressContext); - - progressContext.Refresh(); - - // Keep the progress display alive until all modules complete - while (!progressContext.IsFinished && !cancellationToken.IsCancellationRequested) - { - progressContext.Refresh(); - await Task.Delay(TimeSpan.FromSeconds(1), CancellationToken.None).ConfigureAwait(false); - } - - if (cancellationToken.IsCancellationRequested) - { - progressContext.Refresh(); - } - }); - } - - public void OnModuleStarted(ModuleState moduleState, TimeSpan estimatedDuration) - { - if (_progressContext == null || !_options.Value.ShowProgressInConsole) - { - return; - } - - lock (_progressLock) - { - var moduleName = moduleState.ModuleType.Name; - var progressTask = _progressContext.AddTask(moduleName, new ProgressTaskSettings - { - AutoStart = true, - }); - - _progressTasks[moduleState.Module] = progressTask; - _moduleStateProgressTasks[moduleState] = progressTask; - - // Start ticking progress based on estimated duration - StartProgressTicker(progressTask, estimatedDuration); - } - } - - public void OnModuleCompleted(ModuleState moduleState, bool isSuccessful) - { - if (_progressContext == null || !_options.Value.ShowProgressInConsole) - { - return; - } - - lock (_progressLock) - { - if (_progressTasks.TryGetValue(moduleState.Module, out var progressTask)) - { - if (!progressTask.IsFinished) - { - if (isSuccessful) - { - progressTask.Increment(100 - progressTask.Value); - } - - var moduleName = moduleState.ModuleType.Name; - progressTask.Description = isSuccessful - ? GetSuccessColor(moduleState) + moduleName + "[/]" - : $"[red][[Failed]] {moduleName}[/]"; - - progressTask.StopTask(); - } - - _completedModuleCount++; - _totalProgressTask?.Increment(_progressCalculator.CalculateProgressIncrement(_totalModuleCount)); - - if (_completedModuleCount >= _totalModuleCount) - { - _totalProgressTask?.StopTask(); - } - } - } - } - - public void OnModuleSkipped(ModuleState moduleState) - { - if (_progressContext == null || !_options.Value.ShowProgressInConsole) - { - return; - } - - lock (_progressLock) - { - var moduleName = moduleState.ModuleType.Name; - - if (_progressTasks.TryGetValue(moduleState.Module, out var progressTask)) - { - progressTask.Description = $"[yellow][[Skipped]] {moduleName}[/]"; - if (!progressTask.IsFinished) - { - progressTask.StopTask(); - } - } - else - { - // Module was skipped before it started - var task = _progressContext.AddTask($"[yellow][[Skipped]] {moduleName}[/]"); - task.StopTask(); - _progressTasks[moduleState.Module] = task; - } - - _completedModuleCount++; - _totalProgressTask?.Increment(_progressCalculator.CalculateProgressIncrement(_totalModuleCount)); - - if (_completedModuleCount >= _totalModuleCount) - { - _totalProgressTask?.StopTask(); - } - } - } - - public void OnSubModuleCreated(IModule parentModule, SubModuleBase subModule, TimeSpan estimatedDuration) - { - if (_progressContext == null || !_options.Value.ShowProgressInConsole) - { - return; - } - - lock (_progressLock) - { - if (!_progressTasks.TryGetValue(parentModule, out var parentTask)) - { - return; - } - - var progressTask = _progressContext.AddTaskAfter($"- {subModule.Name}", - new ProgressTaskSettings { AutoStart = true }, parentTask); - - _subModuleProgressTasks[subModule] = progressTask; - - // Start ticking progress based on estimated duration - StartProgressTicker(progressTask, estimatedDuration); - } - } - - public void OnSubModuleCompleted(SubModuleBase subModule, bool isSuccessful) - { - if (_progressContext == null || !_options.Value.ShowProgressInConsole) - { - return; - } - - lock (_progressLock) - { - if (_subModuleProgressTasks.TryGetValue(subModule, out var progressTask)) - { - if (isSuccessful) - { - progressTask.Increment(100 - progressTask.Value); - } - - progressTask.Description = isSuccessful - ? $"[green]- {subModule.Name}[/]" - : $"[red][[Failed]] - {subModule.Name}[/]"; - - progressTask.StopTask(); - } - } - } - - private void StartProgressTicker(ProgressTask progressTask, TimeSpan estimatedDuration) - { - _ = Task.Run(async () => - { - try - { - var tickInfo = _progressCalculator.CalculateTickInfo(estimatedDuration); - - while (progressTask is { IsFinished: false, Value: < 95 } && - tickInfo.TicksPerSecond + progressTask.Value < 95) - { - await Task.Delay(TimeSpan.FromSeconds(1), CancellationToken.None).ConfigureAwait(false); - progressTask.Increment(tickInfo.TicksPerSecond); - } - } - catch (ObjectDisposedException) - { - // Expected when progress context is disposed during module completion - } - catch (InvalidOperationException) - { - // Expected when progress task is stopped or context is in invalid state - } - catch (Exception ex) when (ex is not (OutOfMemoryException or StackOverflowException)) - { - // Suppress other exceptions to prevent unobserved task exceptions - // Progress updates are non-critical UI feedback - } - }, CancellationToken.None); - } - - private static string GetSuccessColor(ModuleState moduleState) - { - // Module state indicates success if State is Completed without issues - return moduleState.State == ModuleExecutionState.Completed ? "[green]" : "[orange3]"; - } - - private static void RegisterIgnoredModules(IReadOnlyList modulesToIgnore, ProgressContext progressContext) - { - foreach (var ignoredModule in modulesToIgnore) - { - progressContext.AddTask($"[yellow][[Ignored]] {ignoredModule.Module.GetType().Name}[/]").StopTask(); - } - } -} diff --git a/src/ModularPipelines/Helpers/SpectreResultsPrinter.cs b/src/ModularPipelines/Helpers/SpectreResultsPrinter.cs index a90310e56f..b230284aa7 100644 --- a/src/ModularPipelines/Helpers/SpectreResultsPrinter.cs +++ b/src/ModularPipelines/Helpers/SpectreResultsPrinter.cs @@ -30,13 +30,13 @@ public void PrintResults(PipelineSummary pipelineSummary) var table = CreateModulesTable(pipelineSummary); - Console.WriteLine(); + System.Console.WriteLine(); AnsiConsole.Write(table.Expand()); // Print execution metrics if available PrintMetrics(pipelineSummary); - Console.WriteLine(); + System.Console.WriteLine(); } private static Table CreateModulesTable(PipelineSummary pipelineSummary) @@ -125,7 +125,7 @@ private static void PrintMetrics(PipelineSummary pipelineSummary) return; } - Console.WriteLine(); + System.Console.WriteLine(); AnsiConsole.MarkupLine("[bold underline]Execution Metrics[/]"); var metricsTable = new Table diff --git a/src/ModularPipelines/Logging/IModuleLoggerContainer.cs b/src/ModularPipelines/Logging/IModuleLoggerContainer.cs deleted file mode 100644 index 358387ca0b..0000000000 --- a/src/ModularPipelines/Logging/IModuleLoggerContainer.cs +++ /dev/null @@ -1,51 +0,0 @@ -using System.Collections.Concurrent; -using Microsoft.Extensions.DependencyInjection; - -namespace ModularPipelines.Logging; - -/// -/// Interface for managing the lifecycle of module loggers. -/// -internal interface IModuleLoggerContainer -{ - /// - /// Cache for constructed generic logger types. - /// MakeGenericType is expensive, so we cache the result per module type. - /// - private static readonly ConcurrentDictionary LoggerTypeCache = new(); - - /// - /// Gets an existing logger or creates one from the scoped service provider. - /// - /// The module type to get/create a logger for. - /// The scoped service provider to resolve from if not cached. - /// The module logger instance. - IModuleLogger GetOrCreateLogger(Type moduleType, IServiceProvider scopedServiceProvider) - { - var loggerType = LoggerTypeCache.GetOrAdd( - moduleType, - static type => typeof(ModuleLogger<>).MakeGenericType(type)); - - return GetLogger(loggerType) - ?? (IModuleLogger)scopedServiceProvider.GetRequiredService(loggerType); - } - - /// - /// Flushes all buffered log events and disposes all registered loggers. - /// Loggers are processed in order of last log written time. - /// - void FlushAndDisposeAll(); - - /// - /// Gets an existing logger for the specified type. - /// - /// The logger type to retrieve. - /// The logger instance, or null if not found. - IModuleLogger? GetLogger(Type type); - - /// - /// Adds a logger to the container registry. - /// - /// The logger to add. - void AddLogger(ModuleLogger logger); -} \ No newline at end of file diff --git a/src/ModularPipelines/Logging/IModuleOutputWriter.cs b/src/ModularPipelines/Logging/IModuleOutputWriter.cs deleted file mode 100644 index 1dda2dd74c..0000000000 --- a/src/ModularPipelines/Logging/IModuleOutputWriter.cs +++ /dev/null @@ -1,50 +0,0 @@ -namespace ModularPipelines.Logging; - -/// -/// Writes grouped/collapsible output for modules. -/// Automatically handles CI-specific formatting, buffering, and section headers with duration/status. -/// -/// -/// This interface replaces ICollapsableLogging with a cleaner API. -/// Output is buffered during module execution and flushed as a grouped block when the module completes. -/// Section headers include module name, status (success/failure), and duration. -/// -/// -/// -/// // Write to module output buffer -/// outputWriter.WriteLine("Building project..."); -/// outputWriter.WriteLine("Build completed successfully"); -/// -/// // Create nested section within module output -/// using (outputWriter.BeginSection("Compile")) -/// { -/// outputWriter.WriteLine("Compiling source files..."); -/// } -/// -/// // Write directly to console (use sparingly) -/// outputWriter.WriteLineDirect("Critical: Starting long operation..."); -/// -/// -public interface IModuleOutputWriter -{ - /// - /// Writes a line to the module's output buffer. - /// Content is flushed when the module completes, grouped under a collapsible section. - /// - /// The message to write. - void WriteLine(string message); - - /// - /// Writes a line directly to console, bypassing the buffer. - /// Use sparingly - for critical messages during long-running operations. - /// - /// The message to write. - void WriteLineDirect(string message); - - /// - /// Creates a nested collapsible section within the module's output. - /// - /// The name of the section. - /// A disposable that ends the section when disposed. - IDisposable BeginSection(string name); -} diff --git a/src/ModularPipelines/Logging/IModuleOutputWriterFactory.cs b/src/ModularPipelines/Logging/IModuleOutputWriterFactory.cs deleted file mode 100644 index d20e43e56b..0000000000 --- a/src/ModularPipelines/Logging/IModuleOutputWriterFactory.cs +++ /dev/null @@ -1,17 +0,0 @@ -using Microsoft.Extensions.Logging; - -namespace ModularPipelines.Logging; - -/// -/// Factory for creating scoped instances. -/// -internal interface IModuleOutputWriterFactory -{ - /// - /// Creates a new output writer for the specified module. - /// - /// The module name for section headers. - /// Optional logger to use for flushing log events. If not provided, uses ILoggerFactory. - /// A new output writer instance. - ModuleOutputWriter Create(string moduleName, ILogger? logger = null); -} diff --git a/src/ModularPipelines/Logging/IOutputFlushLock.cs b/src/ModularPipelines/Logging/IOutputFlushLock.cs deleted file mode 100644 index d0f041f945..0000000000 --- a/src/ModularPipelines/Logging/IOutputFlushLock.cs +++ /dev/null @@ -1,19 +0,0 @@ -namespace ModularPipelines.Logging; - -/// -/// Provides a shared lock for coordinating console output flushing across modules. -/// This ensures that module outputs do not interleave when flushing to the console. -/// -/// -/// Registered as a singleton to share the lock across all instances. -/// This replaces the static lock that was previously in , -/// following proper DI patterns and enabling testability. -/// -internal interface IOutputFlushLock -{ - /// - /// Gets the lock object used to synchronize console output flushing. - /// Use this with lock statement to ensure atomic output blocks. - /// - object Lock { get; } -} diff --git a/src/ModularPipelines/Logging/LogEventBuffer.cs b/src/ModularPipelines/Logging/LogEventBuffer.cs deleted file mode 100644 index 922ee641de..0000000000 --- a/src/ModularPipelines/Logging/LogEventBuffer.cs +++ /dev/null @@ -1,127 +0,0 @@ -namespace ModularPipelines.Logging; - -/// -/// Buffers log events for batch processing with thread-safe operations. -/// Used by ModuleLogger to collect log events during module execution and flush them -/// upon disposal for organized, grouped output. -/// -/// -/// -/// Thread Safety: This class is thread-safe. All public methods can be called -/// concurrently from multiple threads without external synchronization. -/// -/// -/// Synchronization Strategy: Uses a simple lock for mutual exclusion since -/// operations are quick (list add/swap) and contention is expected to be low. -/// The lock ensures atomic add and get-and-clear operations. -/// -/// -/// -/// -/// // Typically used by ModuleLogger internally -/// var buffer = new LogEventBuffer(); -/// -/// // Add log events during execution -/// buffer.Add(logEvent); -/// buffer.Add("Direct string message"); -/// -/// // Check if there are any events -/// if (buffer.HasEvents) -/// { -/// // Get all events and clear the buffer -/// var events = buffer.GetAndClear(); -/// foreach (var evt in events) -/// { -/// // Process event... -/// } -/// } -/// -/// -/// -internal class LogEventBuffer : ILogEventBuffer -{ - private List _events = new(); - private readonly object _lock = new(); - - public void Add(StringOrLogEvent logEvent) - { - lock (_lock) - { - _events.Add(logEvent); - } - } - - public IReadOnlyList GetAndClear() - { - lock (_lock) - { - var events = _events; - _events = new List(); - return events; - } - } - - public bool HasEvents - { - get - { - lock (_lock) - { - return _events.Count > 0; - } - } - } -} - -/// -/// Interface for buffering log events. -/// -internal interface ILogEventBuffer -{ - /// - /// Adds a log event to the buffer. - /// - void Add(StringOrLogEvent logEvent); - - /// - /// Gets all buffered events and clears the buffer. - /// - /// - IReadOnlyList GetAndClear(); - - /// - /// Gets a value indicating whether gets whether the buffer contains any events. - /// - bool HasEvents { get; } -} - -/// -/// Represents either a string or a log event. -/// -internal class StringOrLogEvent -{ - public (Microsoft.Extensions.Logging.LogLevel LogLevel, Microsoft.Extensions.Logging.EventId EventId, object State, Exception? Exception, Func Formatter, string? Category)? LogEvent - { - get; - private init; - } - - public string? StringValue { get; private init; } - - public bool IsString => StringValue != null; - - public static implicit operator StringOrLogEvent(string value) => new() - { - StringValue = value, - }; - - public static implicit operator StringOrLogEvent((Microsoft.Extensions.Logging.LogLevel LogLevel, Microsoft.Extensions.Logging.EventId EventId, object State, Exception? Exception, Func Formatter) value) => new() - { - LogEvent = (value.LogLevel, value.EventId, value.State, value.Exception, value.Formatter, null), - }; - - public static implicit operator StringOrLogEvent((Microsoft.Extensions.Logging.LogLevel LogLevel, Microsoft.Extensions.Logging.EventId EventId, object State, Exception? Exception, Func Formatter, string Category) value) => new() - { - LogEvent = value, - }; -} \ No newline at end of file diff --git a/src/ModularPipelines/Logging/ModuleLogger.cs b/src/ModularPipelines/Logging/ModuleLogger.cs index c17493e6ea..c6b22c6268 100644 --- a/src/ModularPipelines/Logging/ModuleLogger.cs +++ b/src/ModularPipelines/Logging/ModuleLogger.cs @@ -1,5 +1,6 @@ using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging.Abstractions; +using ModularPipelines.Console; using ModularPipelines.Engine; using ModularPipelines.Helpers; @@ -61,27 +62,24 @@ public void SetException(Exception exception) internal class ModuleLogger : ModuleLogger, IModuleLogger, IConsoleWriter, ILogger { - private static readonly string CategoryName = typeof(T).FullName ?? typeof(T).Name; - private readonly ILogger _defaultLogger; private readonly ISecretObfuscator _secretObfuscator; private readonly IFormattedLogValuesObfuscator _formattedLogValuesObfuscator; - private readonly ModuleOutputWriter _outputWriter; + private readonly IModuleOutputBuffer _buffer; private bool _isDisposed; // ReSharper disable once ContextualLoggerProblem - public ModuleLogger(ILogger defaultLogger, - IModuleLoggerContainer moduleLoggerContainer, + public ModuleLogger( + ILogger defaultLogger, ISecretObfuscator secretObfuscator, IFormattedLogValuesObfuscator formattedLogValuesObfuscator, - IModuleOutputWriterFactory outputWriterFactory) + IConsoleCoordinator consoleCoordinator) { _defaultLogger = defaultLogger; _secretObfuscator = secretObfuscator; _formattedLogValuesObfuscator = formattedLogValuesObfuscator; - _outputWriter = outputWriterFactory.Create(typeof(T).Name, defaultLogger); - moduleLoggerContainer.AddLogger(this); + _buffer = consoleCoordinator.GetModuleBuffer(typeof(T)); Disposer.RegisterOnShutdown(this); } @@ -114,9 +112,15 @@ public override void Log(LogLevel logLevel, EventId eventId, TState stat var mappedFormatter = MapFormatter(formatter); - var logEvent = (logLevel, eventId, (object)state!, exception, mappedFormatter, CategoryName); + // Write to buffer for ordered module output during pipeline execution + _buffer.AddLogEvent(logLevel, eventId, state!, exception, mappedFormatter); + + // Create an obfuscating wrapper for the typed formatter + var obfuscatingFormatter = CreateObfuscatingFormatter(formatter); - _outputWriter.AddLogEvent(logEvent); + // Also write directly to the default logger for immediate output + // This ensures logs are captured by file loggers and other providers + _defaultLogger.Log(logLevel, eventId, state, exception, obfuscatingFormatter); LastLogWritten = DateTime.UtcNow; } @@ -135,10 +139,10 @@ public override void Dispose() if (_exception != null) { - _outputWriter.SetException(_exception); + _buffer.SetException(_exception); } - _outputWriter.Dispose(); + _buffer.MarkCompleted(); GC.SuppressFinalize(this); } @@ -146,7 +150,8 @@ public override void Dispose() public override void LogToConsole(string value) { - _outputWriter.WriteLine(value); + var obfuscated = _secretObfuscator.Obfuscate(value, null) ?? value; + _buffer.WriteLine(obfuscated); } private Func MapFormatter(Func? formatter) @@ -162,4 +167,18 @@ public override void LogToConsole(string value) return _secretObfuscator.Obfuscate(formattedString, null) ?? string.Empty; }; } + + private Func CreateObfuscatingFormatter(Func? formatter) + { + if (formatter is null) + { + return (_, _) => string.Empty; + } + + return (state, exception) => + { + var formattedString = formatter.Invoke(state, exception); + return _secretObfuscator.Obfuscate(formattedString, null) ?? string.Empty; + }; + } } \ No newline at end of file diff --git a/src/ModularPipelines/Logging/ModuleLoggerContainer.cs b/src/ModularPipelines/Logging/ModuleLoggerContainer.cs deleted file mode 100644 index 1bcfdd658d..0000000000 --- a/src/ModularPipelines/Logging/ModuleLoggerContainer.cs +++ /dev/null @@ -1,54 +0,0 @@ -using System.Collections.Concurrent; - -namespace ModularPipelines.Logging; - -/// -/// Manages the lifecycle of all active module loggers. -/// Maintains a registry of loggers and coordinates their disposal. -/// -/// -/// This container tracks all module loggers created during pipeline execution -/// and ensures they are properly flushed and disposed in the correct order -/// (ordered by last log written time) to maintain logical output ordering. -/// Uses ConcurrentDictionary for O(1) thread-safe lookups by type. -/// -internal class ModuleLoggerContainer : IModuleLoggerContainer, IDisposable -{ - private readonly ConcurrentDictionary _loggers = new(); - - // Interlocked.Exchange provides both atomicity and full memory barrier, - // making volatile unnecessary for this disposal guard pattern - private int _isDisposed; - - public void FlushAndDisposeAll() - { - // Use Interlocked to ensure only one thread disposes - if (Interlocked.Exchange(ref _isDisposed, 1) == 1) - { - return; - } - - // Values provides a snapshot for safe enumeration - foreach (var logger in _loggers.Values.Where(x => x != null).OrderBy(x => x.LastLogWritten)) - { - logger.Dispose(); - } - } - - public IModuleLogger? GetLogger(Type type) - { - // O(1) lookup via ConcurrentDictionary - return _loggers.TryGetValue(type, out var logger) ? logger : null; - } - - public void AddLogger(ModuleLogger logger) - { - // Thread-safe add; each module type should only have one logger instance - _loggers.TryAdd(logger.GetType(), logger); - } - - public void Dispose() - { - FlushAndDisposeAll(); - } -} diff --git a/src/ModularPipelines/Logging/ModuleLoggerProvider.cs b/src/ModularPipelines/Logging/ModuleLoggerProvider.cs index 4cf63444bf..23c130f06a 100644 --- a/src/ModularPipelines/Logging/ModuleLoggerProvider.cs +++ b/src/ModularPipelines/Logging/ModuleLoggerProvider.cs @@ -9,14 +9,12 @@ namespace ModularPipelines.Logging; /// /// This provider coordinates between: /// - StackTraceModuleDetector: Analyzes call stack to find module type -/// - ModuleLoggerContainer: Caches existing loggers /// - Service provider: Creates new logger instances /// The provider maintains thread-safe singleton behavior per module type. /// internal class ModuleLoggerProvider : IModuleLoggerProvider, IDisposable { private readonly IServiceProvider _serviceProvider; - private readonly IModuleLoggerContainer _moduleLoggerContainer; private readonly IStackTraceModuleDetector _stackTraceDetector; private readonly object _lock = new(); @@ -24,11 +22,9 @@ internal class ModuleLoggerProvider : IModuleLoggerProvider, IDisposable public ModuleLoggerProvider( IServiceProvider serviceProvider, - IModuleLoggerContainer moduleLoggerContainer, IStackTraceModuleDetector stackTraceDetector) { _serviceProvider = serviceProvider; - _moduleLoggerContainer = moduleLoggerContainer; _stackTraceDetector = stackTraceDetector; } @@ -39,9 +35,7 @@ public ModuleLoggerProvider( public IModuleLogger GetLogger(Type type) { var loggerType = typeof(ModuleLogger<>).MakeGenericType(type); - - return _moduleLoggerContainer.GetLogger(loggerType) - ?? (IModuleLogger)_serviceProvider.GetRequiredService(loggerType); + return (IModuleLogger)_serviceProvider.GetRequiredService(loggerType); } public IModuleLogger GetLogger() @@ -63,7 +57,7 @@ public IModuleLogger GetLogger() var moduleType = ModuleLogger.CurrentModuleType.Value; if (moduleType != null) { - return _moduleLogger = CreateLogger(moduleType); + return _moduleLogger = GetLogger(moduleType); } // Fallback: use stack trace inspection (for edge cases where AsyncLocal context is lost) @@ -74,7 +68,7 @@ public IModuleLogger GetLogger() throw new InvalidOperationException("Could not detect module type from call stack."); } - return _moduleLogger = CreateLogger(detectedType); + return _moduleLogger = GetLogger(detectedType); } } @@ -82,12 +76,4 @@ public void Dispose() { _moduleLogger?.Dispose(); } - - private IModuleLogger CreateLogger(Type module) - { - var loggerType = typeof(ModuleLogger<>).MakeGenericType(module); - - return _moduleLoggerContainer.GetLogger(loggerType) - ?? (IModuleLogger)_serviceProvider.GetRequiredService(loggerType); - } } diff --git a/src/ModularPipelines/Logging/ModuleOutputContext.cs b/src/ModularPipelines/Logging/ModuleOutputContext.cs deleted file mode 100644 index 8cbaac5ba2..0000000000 --- a/src/ModularPipelines/Logging/ModuleOutputContext.cs +++ /dev/null @@ -1,57 +0,0 @@ -namespace ModularPipelines.Logging; - -/// -/// Tracks module execution state for output formatting. -/// Used by to format section headers with duration and status. -/// -internal class ModuleOutputContext -{ - /// - /// Gets the module name for section headers. - /// - public string ModuleName { get; } - - /// - /// Gets the UTC time when the module started executing. - /// - public DateTime StartTimeUtc { get; } - - /// - /// Gets or sets the exception if the module failed. - /// - public Exception? Exception { get; set; } - - /// - /// Initializes a new instance of the class. - /// - /// The module name. - public ModuleOutputContext(string moduleName) - { - ModuleName = moduleName; - StartTimeUtc = DateTime.UtcNow; - } - - /// - /// Gets the duration since the module started. - /// - public TimeSpan GetDuration() => DateTime.UtcNow - StartTimeUtc; - - /// - /// Formats the section header with module name, status, and duration. - /// - /// Formatted section header string. - public string FormatSectionHeader() - { - var duration = GetDuration(); - var durationStr = duration.TotalSeconds >= 60 - ? $"{duration.TotalMinutes:F1}m" - : $"{duration.TotalSeconds:F1}s"; - - if (Exception != null) - { - return $"{ModuleName} \u2717 ({durationStr}) - {Exception.GetType().Name}"; - } - - return $"{ModuleName} \u2713 ({durationStr})"; - } -} diff --git a/src/ModularPipelines/Logging/ModuleOutputWriter.cs b/src/ModularPipelines/Logging/ModuleOutputWriter.cs deleted file mode 100644 index 2f6fb9b117..0000000000 --- a/src/ModularPipelines/Logging/ModuleOutputWriter.cs +++ /dev/null @@ -1,209 +0,0 @@ -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Logging; -using ModularPipelines.Engine; - -namespace ModularPipelines.Logging; - -/// -/// Consolidated implementation for module output handling. -/// Manages buffering, CI-specific formatting, and section headers with duration/status. -/// -/// -/// Uses a shared lock (via ) to ensure module outputs don't interleave. -/// Each module's output is flushed as an atomic block when the module completes. -/// -internal class ModuleOutputWriter : IModuleOutputWriter, IDisposable -{ - private readonly IServiceScope _scope; - private readonly IOutputFlushLock _flushLock; - private readonly ILogEventBuffer _buffer; - private readonly IBuildSystemFormatter _formatter; - private readonly IConsoleWriter _consoleWriter; - private readonly ISecretObfuscator _secretObfuscator; - private readonly ILogger _logger; - private readonly ModuleOutputContext _context; - private readonly object _writeLock = new(); - private bool _isDisposed; - - public ModuleOutputWriter( - IServiceScope scope, - IOutputFlushLock flushLock, - IBuildSystemFormatterProvider formatterProvider, - IConsoleWriter consoleWriter, - ISecretObfuscator secretObfuscator, - ILogger logger, - string moduleName) - { - _scope = scope; - _flushLock = flushLock; - _buffer = scope.ServiceProvider.GetRequiredService(); - _formatter = formatterProvider.GetFormatter(); - _consoleWriter = consoleWriter; - _secretObfuscator = secretObfuscator; - _logger = logger; - _context = new ModuleOutputContext(moduleName); - } - - /// - public void WriteLine(string message) - { - ArgumentNullException.ThrowIfNull(message); - - lock (_writeLock) - { - if (_isDisposed) - { - return; - } - - var obfuscated = _secretObfuscator.Obfuscate(message, null) ?? message; - _buffer.Add(obfuscated); - } - } - - /// - /// Adds a log event to the buffer. - /// This method is for internal framework use only. - /// - internal void AddLogEvent(StringOrLogEvent logEvent) - { - lock (_writeLock) - { - if (_isDisposed) - { - return; - } - - _buffer.Add(logEvent); - } - } - - /// - /// - /// This method is not thread-safe and does not use internal locking. - /// Callers should ensure single-threaded access or provide their own synchronization. - /// Intended for use in scenarios where the caller controls execution order (e.g., critical messages during long operations). - /// - public void WriteLineDirect(string message) - { - ArgumentNullException.ThrowIfNull(message); - - var obfuscated = _secretObfuscator.Obfuscate(message, null) ?? message; - _consoleWriter.LogToConsole(obfuscated); - } - - /// - public IDisposable BeginSection(string name) - { - ArgumentNullException.ThrowIfNull(name); - - return new OutputSection(this, name); - } - - /// - /// Sets the exception for failed module status in section header. - /// This method is for internal framework use only and is called by the module execution engine - /// when a module fails to record the exception for display in the output section header. - /// - /// The exception that caused the module to fail. - internal void SetException(Exception exception) - { - ArgumentNullException.ThrowIfNull(exception); - - _context.Exception = exception; - } - - /// - /// Flushes all buffered output with section header/footer. - /// - /// - /// No finalizer is needed since this class only manages managed resources. - /// If Dispose is not called, the IServiceScope will be cleaned up by GC. - /// - public void Dispose() - { - lock (_writeLock) - { - if (_isDisposed) - { - return; - } - - _isDisposed = true; - } - - if (!_buffer.HasEvents) - { - _scope.Dispose(); - return; - } - - lock (_flushLock.Lock) - { - var sectionHeader = _context.FormatSectionHeader(); - - // Start collapsible section - var startCommand = _formatter.GetStartBlockCommand(sectionHeader); - if (startCommand != null) - { - _consoleWriter.LogToConsole(startCommand); - } - - // Flush all buffered content - var events = _buffer.GetAndClear(); - foreach (var evt in events) - { - if (evt.IsString && evt.StringValue != null) - { - _consoleWriter.LogToConsole(evt.StringValue); - } - else if (evt.LogEvent.HasValue) - { - var logEvent = evt.LogEvent.Value; - - // Replay the log event through the injected logger - _logger.Log(logEvent.LogLevel, logEvent.EventId, logEvent.State, logEvent.Exception, logEvent.Formatter); - } - } - - // End collapsible section - var endCommand = _formatter.GetEndBlockCommand(sectionHeader); - if (endCommand != null) - { - _consoleWriter.LogToConsole(endCommand); - } - } - - _scope.Dispose(); - } - - /// - /// Represents a nested output section within module output. - /// - private sealed class OutputSection : IDisposable - { - private readonly ModuleOutputWriter _writer; - private readonly string _name; - - public OutputSection(ModuleOutputWriter writer, string name) - { - _writer = writer; - _name = name; - - var startCommand = _writer._formatter.GetStartBlockCommand(name); - if (startCommand != null) - { - _writer._buffer.Add(startCommand); - } - } - - public void Dispose() - { - var endCommand = _writer._formatter.GetEndBlockCommand(_name); - if (endCommand != null) - { - _writer._buffer.Add(endCommand); - } - } - } -} diff --git a/src/ModularPipelines/Logging/ModuleOutputWriterFactory.cs b/src/ModularPipelines/Logging/ModuleOutputWriterFactory.cs deleted file mode 100644 index d06050b113..0000000000 --- a/src/ModularPipelines/Logging/ModuleOutputWriterFactory.cs +++ /dev/null @@ -1,53 +0,0 @@ -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Logging; -using ModularPipelines.Engine; - -namespace ModularPipelines.Logging; - -/// -/// Factory for creating scoped instances. -/// -internal class ModuleOutputWriterFactory : IModuleOutputWriterFactory -{ - private readonly IServiceProvider _serviceProvider; - private readonly IOutputFlushLock _flushLock; - private readonly IBuildSystemFormatterProvider _formatterProvider; - private readonly IConsoleWriter _consoleWriter; - private readonly ISecretObfuscator _secretObfuscator; - private readonly ILoggerFactory _loggerFactory; - - public ModuleOutputWriterFactory( - IServiceProvider serviceProvider, - IOutputFlushLock flushLock, - IBuildSystemFormatterProvider formatterProvider, - IConsoleWriter consoleWriter, - ISecretObfuscator secretObfuscator, - ILoggerFactory loggerFactory) - { - _serviceProvider = serviceProvider; - _flushLock = flushLock; - _formatterProvider = formatterProvider; - _consoleWriter = consoleWriter; - _secretObfuscator = secretObfuscator; - _loggerFactory = loggerFactory; - } - - /// - public ModuleOutputWriter Create(string moduleName, ILogger? logger = null) - { - // Get a fresh scope for this module - var scope = _serviceProvider.CreateScope(); - - // Use provided logger or create one from factory - var effectiveLogger = logger ?? _loggerFactory.CreateLogger(moduleName); - - return new ModuleOutputWriter( - scope, - _flushLock, - _formatterProvider, - _consoleWriter, - _secretObfuscator, - effectiveLogger, - moduleName); - } -} diff --git a/src/ModularPipelines/Logging/OutputFlushLock.cs b/src/ModularPipelines/Logging/OutputFlushLock.cs deleted file mode 100644 index 88bd475315..0000000000 --- a/src/ModularPipelines/Logging/OutputFlushLock.cs +++ /dev/null @@ -1,11 +0,0 @@ -namespace ModularPipelines.Logging; - -/// -/// Singleton implementation of that provides -/// a shared lock for coordinating console output flushing across modules. -/// -internal sealed class OutputFlushLock : IOutputFlushLock -{ - /// - public object Lock { get; } = new(); -} diff --git a/test/ModularPipelines.UnitTests/ModuleLoggerTests.cs b/test/ModularPipelines.UnitTests/ModuleLoggerTests.cs index 8523004248..28dab6cd26 100644 --- a/test/ModularPipelines.UnitTests/ModuleLoggerTests.cs +++ b/test/ModularPipelines.UnitTests/ModuleLoggerTests.cs @@ -1,4 +1,3 @@ -using System.Text; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; using ModularPipelines.Attributes; @@ -51,16 +50,17 @@ public override async Task ExecuteAsync(IModuleContext context, Cancellati } [Test] - public async Task Can_Write_To_Console_Successfully() + public async Task LogToConsole_Does_Not_Write_To_File_Logger() { - var consoleStringBuilder = new StringBuilder(); + // This test verifies that LogToConsole output goes to console buffers, + // NOT to file loggers. The console output itself is verified implicitly + // through the full integration tests. var file = File.GetNewTemporaryFilePath(); var host = await TestPipelineHostBuilder.Create() .ConfigureServices((_, collection) => { collection.AddLogging(builder => { builder.AddFile(file); }); - collection.AddSingleton(new StringBuilderConsoleWriter(consoleStringBuilder)); }) .AddModule() .BuildHostAsync(); @@ -69,8 +69,7 @@ public async Task Can_Write_To_Console_Successfully() await host.DisposeAsync(); - var stringOutput = consoleStringBuilder.ToString(); - await Assert.That(stringOutput).Contains(RandomString); + // The key behavior: LogToConsole output should NOT appear in file logs await Assert.That(await file.ReadAsync()).DoesNotContain(RandomString); } diff --git a/test/ModularPipelines.UnitTests/StringBuilderConsoleWriter.cs b/test/ModularPipelines.UnitTests/StringBuilderConsoleWriter.cs deleted file mode 100644 index b10bae2df1..0000000000 --- a/test/ModularPipelines.UnitTests/StringBuilderConsoleWriter.cs +++ /dev/null @@ -1,18 +0,0 @@ -using System.Text; - -namespace ModularPipelines.UnitTests; - -public class StringBuilderConsoleWriter : IConsoleWriter -{ - private readonly StringBuilder _stringBuilder; - - public StringBuilderConsoleWriter(StringBuilder stringBuilder) - { - _stringBuilder = stringBuilder; - } - - public void LogToConsole(string value) - { - _stringBuilder.AppendLine(value); - } -} \ No newline at end of file