From 0d959e317e401d490680b38a4518867e6027acb6 Mon Sep 17 00:00:00 2001 From: HueByte Date: Sat, 21 Feb 2026 16:21:08 +0100 Subject: [PATCH 1/4] feat: extract channel management to IChannelService and implement CRUD operations --- docs/changelog/v0.2.5.md | 17 +- src/EchoHub.Client/UI/MainWindow.cs | 2 +- src/EchoHub.Core/Contracts/IChannelService.cs | 22 ++ src/EchoHub.Core/Contracts/IChatService.cs | 6 +- src/EchoHub.Core/DTOs/CommonDtos.cs | 17 ++ src/EchoHub.Server.Irc/IrcCommandHandler.cs | 7 +- src/EchoHub.Server.Irc/IrcGatewayService.cs | 3 +- .../Controllers/ChannelsController.cs | 147 +++-------- src/EchoHub.Server/Program.cs | 1 + src/EchoHub.Server/Services/ChannelService.cs | 247 ++++++++++++++++++ src/EchoHub.Server/Services/ChatService.cs | 76 +----- .../Irc/IrcCommandHandlerTests.cs | 13 +- src/EchoHub.Tests/Irc/TestHelpers.cs | 47 +++- 13 files changed, 402 insertions(+), 203 deletions(-) create mode 100644 src/EchoHub.Core/Contracts/IChannelService.cs create mode 100644 src/EchoHub.Server/Services/ChannelService.cs diff --git a/docs/changelog/v0.2.5.md b/docs/changelog/v0.2.5.md index adf5cd2..5922090 100644 --- a/docs/changelog/v0.2.5.md +++ b/docs/changelog/v0.2.5.md @@ -57,10 +57,23 @@ - **Fixed: `JoinedChannels` race condition** — `IrcClientConnection.JoinedChannels` replaced with thread-safe methods (`JoinChannel`, `LeaveChannel`, `IsInChannel`, `GetJoinedChannels`) using lock synchronization; prevents crashes when broadcaster threads read while the command handler writes - **Fixed: `RequireRegistered` fire-and-forget** — converted from sync `bool` to `async Task` (`RequireRegisteredAsync`) so the error reply is properly awaited before the handler returns +### ChannelService Extraction + +- Extracted channel management logic from `ChannelsController` and `ChatService` into a dedicated `IChannelService` / `ChannelService` +- `ChannelsController` is now a thin adapter — delegates CRUD operations to `IChannelService` and maps `ChannelError` to HTTP status codes +- `ChatService.JoinChannelAsync` delegates channel validation + membership to `IChannelService.EnsureChannelMembershipAsync()` +- New `ChannelOperationResult` result type with `ChannelError` enum for typed error handling across service boundaries +- IRC gateway uses `IChannelService` for topic queries and channel listing (instead of `IChatService`) + +### EchoHub Branding + +- Status bar "EchoHub" text now uses golden color (218, 165, 32) + ## Infrastructure - Audio MIME types in `FilesController` (mp3, wav, ogg, flac, aac, m4a, wma) +- `FakeChannelService` test helper added for IRC unit tests - Test suites: ChatLine, CommandHandler, DataMigrationService, FileValidationHelper, ImageToAsciiService, IrcMessageFormatter, JwtTokenService, LinkEmbedService - IRC abstraction layer test suite: IrcMessage parsing, IrcMessageFormatter, IrcClientConnection, IrcCommandHandler, IrcBroadcaster -- Test helpers: `TestDuplexStream`, `TestIrcConnectionFactory`, `FakeChatService`, `FakeEncryptionService` for IRC unit testing without network I/O -- 214 total tests +- Test helpers: `TestDuplexStream`, `TestIrcConnectionFactory`, `FakeChatService`, `FakeChannelService`, `FakeEncryptionService` for IRC unit testing without network I/O +- 346 total tests diff --git a/src/EchoHub.Client/UI/MainWindow.cs b/src/EchoHub.Client/UI/MainWindow.cs index 4536f16..1c45e8c 100644 --- a/src/EchoHub.Client/UI/MainWindow.cs +++ b/src/EchoHub.Client/UI/MainWindow.cs @@ -709,7 +709,7 @@ public sealed class MainWindow : Runnable private static readonly Attribute StatusConnectedAttr = new(new Color(0, 200, 0), Color.Transparent); private static readonly Attribute StatusDisconnectedAttr = new(new Color(220, 50, 50), Color.Transparent); private static readonly Attribute StatusTransitionalAttr = new(new Color(220, 180, 0), Color.Transparent); - private static readonly Attribute StatusBrandAttr = new(new Color(100, 160, 255), Color.Transparent); + private static readonly Attribute StatusBrandAttr = new(new Color(218, 165, 32), Color.Transparent); private void OnStatusBarDrawContent(object? sender, DrawEventArgs e) { diff --git a/src/EchoHub.Core/Contracts/IChannelService.cs b/src/EchoHub.Core/Contracts/IChannelService.cs new file mode 100644 index 0000000..e445160 --- /dev/null +++ b/src/EchoHub.Core/Contracts/IChannelService.cs @@ -0,0 +1,22 @@ +using EchoHub.Core.DTOs; + +namespace EchoHub.Core.Contracts; + +public interface IChannelService +{ + // Channel CRUD + Task> GetChannelsAsync(Guid userId, int offset, int limit); + Task CreateChannelAsync(Guid creatorUserId, string name, string? topic, bool isPublic); + Task UpdateTopicAsync(Guid callerUserId, string channelName, string? topic); + Task DeleteChannelAsync(Guid callerUserId, string channelName); + + // Channel queries + Task<(string? Topic, bool Exists)> GetChannelTopicAsync(string channelName); + Task> GetChannelListAsync(); + Task GetChannelByNameAsync(string channelName); + + // Membership + Task<(bool Success, string? Error)> EnsureChannelMembershipAsync(Guid userId, string channelName); +} + +public record ChannelListItem(string Name, string? Topic, int OnlineCount); diff --git a/src/EchoHub.Core/Contracts/IChatService.cs b/src/EchoHub.Core/Contracts/IChatService.cs index c4102fb..03ea1c2 100644 --- a/src/EchoHub.Core/Contracts/IChatService.cs +++ b/src/EchoHub.Core/Contracts/IChatService.cs @@ -25,12 +25,8 @@ public interface IChatService Task BroadcastMessageAsync(string channelName, MessageDto message); Task BroadcastChannelUpdatedAsync(ChannelDto channel, string? channelName = null); - // Query operations (used by IRC gateway for WHOIS, TOPIC, LIST, AUTH) + // Query operations (used by IRC gateway for WHOIS, AUTH) Task GetUserProfileAsync(string username); - Task<(string? Topic, bool Exists)> GetChannelTopicAsync(string channelName); - Task> GetChannelListAsync(); Task> GetChannelsForUserAsync(string username); Task<(Guid UserId, string Username)?> AuthenticateUserAsync(string username, string password); } - -public record ChannelListItem(string Name, string? Topic, int OnlineCount); diff --git a/src/EchoHub.Core/DTOs/CommonDtos.cs b/src/EchoHub.Core/DTOs/CommonDtos.cs index a5d73c4..11e7de4 100644 --- a/src/EchoHub.Core/DTOs/CommonDtos.cs +++ b/src/EchoHub.Core/DTOs/CommonDtos.cs @@ -3,3 +3,20 @@ namespace EchoHub.Core.DTOs; public record ErrorResponse(string Error, string? Detail = null); public record PaginatedResponse(List Items, int Total, int Offset, int Limit); + +public enum ChannelError +{ + ValidationFailed, + AlreadyExists, + NotFound, + Forbidden, + Protected +} + +public record ChannelOperationResult(ChannelDto? Channel, ChannelError? Error, string? ErrorMessage) +{ + public bool IsSuccess => Error is null; + + public static ChannelOperationResult Success(ChannelDto channel) => new(channel, null, null); + public static ChannelOperationResult Fail(ChannelError error, string message) => new(null, error, message); +} diff --git a/src/EchoHub.Server.Irc/IrcCommandHandler.cs b/src/EchoHub.Server.Irc/IrcCommandHandler.cs index d1b413e..4d1fa30 100644 --- a/src/EchoHub.Server.Irc/IrcCommandHandler.cs +++ b/src/EchoHub.Server.Irc/IrcCommandHandler.cs @@ -12,6 +12,7 @@ public sealed class IrcCommandHandler private readonly IrcClientConnection _conn; private readonly IrcOptions _options; private readonly IChatService _chatService; + private readonly IChannelService _channelService; private readonly IMessageEncryptionService _encryption; private readonly ILogger _logger; @@ -21,12 +22,14 @@ public sealed class IrcCommandHandler IrcClientConnection conn, IrcOptions options, IChatService chatService, + IChannelService channelService, IMessageEncryptionService encryption, ILogger logger) { _conn = conn; _options = options; _chatService = chatService; + _channelService = channelService; _encryption = encryption; _logger = logger; } @@ -481,7 +484,7 @@ public sealed class IrcCommandHandler private async Task SendChannelTopicAsync(string channelName) { - var (topic, exists) = await _chatService.GetChannelTopicAsync(channelName); + var (topic, exists) = await _channelService.GetChannelTopicAsync(channelName); if (!exists) return; @@ -587,7 +590,7 @@ public sealed class IrcCommandHandler { if (!await RequireRegisteredAsync()) return; - var channels = await _chatService.GetChannelListAsync(); + var channels = await _channelService.GetChannelListAsync(); foreach (var ch in channels) { diff --git a/src/EchoHub.Server.Irc/IrcGatewayService.cs b/src/EchoHub.Server.Irc/IrcGatewayService.cs index b0fe30a..d9f555c 100644 --- a/src/EchoHub.Server.Irc/IrcGatewayService.cs +++ b/src/EchoHub.Server.Irc/IrcGatewayService.cs @@ -120,9 +120,10 @@ public sealed class IrcGatewayService : BackgroundService try { chatService = _services.GetRequiredService(); + var channelService = _services.GetRequiredService(); var encryption = _services.GetRequiredService(); var handler = new IrcCommandHandler( - connection, _options, chatService, encryption, _logger); + connection, _options, chatService, channelService, encryption, _logger); await handler.RunAsync(ct); } diff --git a/src/EchoHub.Server/Controllers/ChannelsController.cs b/src/EchoHub.Server/Controllers/ChannelsController.cs index a0c0725..ab222a0 100644 --- a/src/EchoHub.Server/Controllers/ChannelsController.cs +++ b/src/EchoHub.Server/Controllers/ChannelsController.cs @@ -8,7 +8,6 @@ using EchoHub.Server.Services; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.RateLimiting; -using Microsoft.EntityFrameworkCore; namespace EchoHub.Server.Controllers; @@ -18,6 +17,7 @@ namespace EchoHub.Server.Controllers; [EnableRateLimiting("general")] public class ChannelsController : ControllerBase { + private readonly IChannelService _channelService; private readonly EchoHubDbContext _db; private readonly FileStorageService _fileStorage; private readonly ImageToAsciiService _asciiService; @@ -26,6 +26,7 @@ public class ChannelsController : ControllerBase private readonly IMessageEncryptionService _encryption; public ChannelsController( + IChannelService channelService, EchoHubDbContext db, FileStorageService fileStorage, ImageToAsciiService asciiService, @@ -33,6 +34,7 @@ public class ChannelsController : ControllerBase IChatService chatService, IMessageEncryptionService encryption) { + _channelService = channelService; _db = db; _fileStorage = fileStorage; _asciiService = asciiService; @@ -48,87 +50,29 @@ public class ChannelsController : ControllerBase if (userIdClaim is null) return Unauthorized(new ErrorResponse("Authentication required.")); - var userId = Guid.Parse(userIdClaim); offset = Math.Max(0, offset); limit = Math.Clamp(limit, 1, 100); - // Ensure #general always exists - if (!await _db.Channels.AnyAsync(c => c.Name == HubConstants.DefaultChannel)) - { - _db.Channels.Add(new Channel - { - Id = Guid.NewGuid(), - Name = HubConstants.DefaultChannel, - Topic = "General discussion", - CreatedByUserId = Guid.Empty, - }); - await _db.SaveChangesAsync(); - } - - // Public channels + private channels the user has joined - var query = _db.Channels.Where(c => - c.IsPublic || _db.ChannelMemberships.Any(m => m.ChannelId == c.Id && m.UserId == userId)); - var total = await query.CountAsync(); - - var channels = await query - .OrderBy(c => c.Name) - .Skip(offset) - .Take(limit) - .Select(c => new ChannelDto( - c.Id, - c.Name, - c.Topic, - c.IsPublic, - c.Messages.Count, - c.CreatedAt)) - .ToListAsync(); - - return Ok(new PaginatedResponse(channels, total, offset, limit)); + var result = await _channelService.GetChannelsAsync(Guid.Parse(userIdClaim), offset, limit); + return Ok(result); } [HttpPost] public async Task CreateChannel([FromBody] CreateChannelRequest request) { - if (string.IsNullOrWhiteSpace(request.Name)) - return BadRequest(new ErrorResponse("Channel name is required.")); - - var channelName = request.Name.ToLowerInvariant().Trim(); - - if (!ValidationConstants.ChannelNameRegex().IsMatch(channelName)) - return BadRequest(new ErrorResponse("Channel name must be 2-100 characters and contain only letters, digits, underscores, or hyphens.")); - - if (await _db.Channels.AnyAsync(c => c.Name == channelName)) - return Conflict(new ErrorResponse($"Channel '{channelName}' already exists.")); - var userIdClaim = User.FindFirstValue(ClaimTypes.NameIdentifier); if (userIdClaim is null) return Unauthorized(new ErrorResponse("Authentication required.")); - var channel = new Channel - { - Id = Guid.NewGuid(), - Name = channelName, - Topic = request.Topic?.Trim(), - IsPublic = request.IsPublic, - CreatedByUserId = Guid.Parse(userIdClaim), - }; + var result = await _channelService.CreateChannelAsync( + Guid.Parse(userIdClaim), request.Name, request.Topic, request.IsPublic); + if (!result.IsSuccess) + return MapChannelError(result); - _db.Channels.Add(channel); + if (result.Channel!.IsPublic) + await _chatService.BroadcastChannelUpdatedAsync(result.Channel); - // Creator automatically becomes a member - _db.ChannelMemberships.Add(new ChannelMembership - { - UserId = Guid.Parse(userIdClaim), - ChannelId = channel.Id, - }); - - await _db.SaveChangesAsync(); - - var dto = new ChannelDto(channel.Id, channel.Name, channel.Topic, channel.IsPublic, 0, channel.CreatedAt); - if (channel.IsPublic) - await _chatService.BroadcastChannelUpdatedAsync(dto); - - return Created($"/api/channels/{channelName}", dto); + return Created($"/api/channels/{result.Channel.Name}", result.Channel); } [HttpPut("{channel}/topic")] @@ -138,26 +82,13 @@ public class ChannelsController : ControllerBase if (userIdClaim is null) return Unauthorized(new ErrorResponse("Authentication required.")); - var channelName = channel.ToLowerInvariant().Trim(); - var dbChannel = await _db.Channels.FirstOrDefaultAsync(c => c.Name == channelName); + var result = await _channelService.UpdateTopicAsync( + Guid.Parse(userIdClaim), channel, request.Topic); + if (!result.IsSuccess) + return MapChannelError(result); - if (dbChannel is null) - return NotFound(new ErrorResponse($"Channel '{channelName}' does not exist.")); - - if (dbChannel.CreatedByUserId != Guid.Parse(userIdClaim)) - return StatusCode(403, new ErrorResponse("Only the channel creator can update the topic.")); - - if (request.Topic is not null && request.Topic.Length > ValidationConstants.MaxChannelTopicLength) - return BadRequest(new ErrorResponse($"Topic must not exceed {ValidationConstants.MaxChannelTopicLength} characters.")); - - dbChannel.Topic = request.Topic?.Trim(); - await _db.SaveChangesAsync(); - - var messageCount = await _db.Messages.CountAsync(m => m.ChannelId == dbChannel.Id); - var dto = new ChannelDto(dbChannel.Id, dbChannel.Name, dbChannel.Topic, dbChannel.IsPublic, messageCount, dbChannel.CreatedAt); - await _chatService.BroadcastChannelUpdatedAsync(dto, channelName); - - return Ok(dto); + await _chatService.BroadcastChannelUpdatedAsync(result.Channel!, channel.ToLowerInvariant().Trim()); + return Ok(result.Channel); } [HttpDelete("{channel}")] @@ -167,23 +98,9 @@ public class ChannelsController : ControllerBase if (userIdClaim is null) return Unauthorized(new ErrorResponse("Authentication required.")); - var channelName = channel.ToLowerInvariant().Trim(); - - if (channelName == HubConstants.DefaultChannel) - return BadRequest(new ErrorResponse($"The '{HubConstants.DefaultChannel}' channel cannot be deleted.")); - - var dbChannel = await _db.Channels.FirstOrDefaultAsync(c => c.Name == channelName); - - if (dbChannel is null) - return NotFound(new ErrorResponse($"Channel '{channelName}' does not exist.")); - - var userId = Guid.Parse(userIdClaim); - var caller = await _db.Users.FindAsync(userId); - if (dbChannel.CreatedByUserId != userId && (caller is null || caller.Role < ServerRole.Admin)) - return StatusCode(403, new ErrorResponse("Only the channel creator or an admin can delete the channel.")); - - _db.Channels.Remove(dbChannel); - await _db.SaveChangesAsync(); + var result = await _channelService.DeleteChannelAsync(Guid.Parse(userIdClaim), channel); + if (!result.IsSuccess) + return MapChannelError(result); return NoContent(); } @@ -203,8 +120,8 @@ public class ChannelsController : ControllerBase if (!ValidationConstants.ChannelNameRegex().IsMatch(channelName)) return BadRequest(new ErrorResponse("Invalid channel name format.")); - var dbChannel = await _db.Channels.FirstOrDefaultAsync(c => c.Name == channelName); - if (dbChannel is null) + var channelDto = await _channelService.GetChannelByNameAsync(channelName); + if (channelDto is null) return NotFound(new ErrorResponse($"Channel '{channelName}' does not exist.")); if (!Request.HasFormContentType || Request.Form.Files.Count == 0) @@ -250,7 +167,7 @@ public class ChannelsController : ControllerBase AttachmentUrl = attachmentUrl, AttachmentFileName = file.FileName, SentAt = DateTimeOffset.UtcNow, - ChannelId = dbChannel.Id, + ChannelId = channelDto.Id, SenderUserId = userId, SenderUsername = usernameClaim, }; @@ -290,8 +207,8 @@ public class ChannelsController : ControllerBase if (!ValidationConstants.ChannelNameRegex().IsMatch(channelName)) return BadRequest(new ErrorResponse("Invalid channel name format.")); - var dbChannel = await _db.Channels.FirstOrDefaultAsync(c => c.Name == channelName); - if (dbChannel is null) + var channelDto = await _channelService.GetChannelByNameAsync(channelName); + if (channelDto is null) return NotFound(new ErrorResponse($"Channel '{channelName}' does not exist.")); if (string.IsNullOrWhiteSpace(request.Url)) @@ -370,7 +287,7 @@ public class ChannelsController : ControllerBase AttachmentUrl = attachmentUrl, AttachmentFileName = fileName, SentAt = DateTimeOffset.UtcNow, - ChannelId = dbChannel.Id, + ChannelId = channelDto.Id, SenderUserId = userId, SenderUsername = usernameClaim, }; @@ -394,4 +311,14 @@ public class ChannelsController : ControllerBase return Ok(messageDto); } + + private IActionResult MapChannelError(ChannelOperationResult result) => result.Error switch + { + ChannelError.ValidationFailed => BadRequest(new ErrorResponse(result.ErrorMessage!)), + ChannelError.AlreadyExists => Conflict(new ErrorResponse(result.ErrorMessage!)), + ChannelError.NotFound => NotFound(new ErrorResponse(result.ErrorMessage!)), + ChannelError.Forbidden => StatusCode(403, new ErrorResponse(result.ErrorMessage!)), + ChannelError.Protected => BadRequest(new ErrorResponse(result.ErrorMessage!)), + _ => BadRequest(new ErrorResponse(result.ErrorMessage ?? "Unknown error.")), + }; } diff --git a/src/EchoHub.Server/Program.cs b/src/EchoHub.Server/Program.cs index 4a00acd..2ab4dcc 100644 --- a/src/EchoHub.Server/Program.cs +++ b/src/EchoHub.Server/Program.cs @@ -115,6 +115,7 @@ while (true) // ── Chat Service + Broadcasters ───────────────────────────────────── builder.Services.AddSingleton(); + builder.Services.AddSingleton(); builder.Services.AddSingleton(); // ── IRC Gateway (optional) ────────────────────────────────────────── diff --git a/src/EchoHub.Server/Services/ChannelService.cs b/src/EchoHub.Server/Services/ChannelService.cs new file mode 100644 index 0000000..d4ef85e --- /dev/null +++ b/src/EchoHub.Server/Services/ChannelService.cs @@ -0,0 +1,247 @@ +using EchoHub.Core.Constants; +using EchoHub.Core.Contracts; +using EchoHub.Core.DTOs; +using EchoHub.Core.Models; +using EchoHub.Server.Data; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; + +namespace EchoHub.Server.Services; + +public class ChannelService : IChannelService +{ + private readonly IServiceScopeFactory _scopeFactory; + private readonly PresenceTracker _presenceTracker; + private readonly ILogger _logger; + + public ChannelService( + IServiceScopeFactory scopeFactory, + PresenceTracker presenceTracker, + ILogger logger) + { + _scopeFactory = scopeFactory; + _presenceTracker = presenceTracker; + _logger = logger; + } + + public async Task> GetChannelsAsync(Guid userId, int offset, int limit) + { + using var scope = _scopeFactory.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + + await EnsureDefaultChannelAsync(db); + + var query = db.Channels.Where(c => + c.IsPublic || db.ChannelMemberships.Any(m => m.ChannelId == c.Id && m.UserId == userId)); + var total = await query.CountAsync(); + + var channels = await query + .OrderBy(c => c.Name) + .Skip(offset) + .Take(limit) + .Select(c => new ChannelDto( + c.Id, c.Name, c.Topic, c.IsPublic, c.Messages.Count, c.CreatedAt)) + .ToListAsync(); + + return new PaginatedResponse(channels, total, offset, limit); + } + + public async Task CreateChannelAsync( + Guid creatorUserId, string name, string? topic, bool isPublic) + { + if (string.IsNullOrWhiteSpace(name)) + return ChannelOperationResult.Fail(ChannelError.ValidationFailed, "Channel name is required."); + + var channelName = name.ToLowerInvariant().Trim(); + + if (!ValidationConstants.ChannelNameRegex().IsMatch(channelName)) + return ChannelOperationResult.Fail(ChannelError.ValidationFailed, + "Channel name must be 2-100 characters and contain only letters, digits, underscores, or hyphens."); + + using var scope = _scopeFactory.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + + if (await db.Channels.AnyAsync(c => c.Name == channelName)) + return ChannelOperationResult.Fail(ChannelError.AlreadyExists, $"Channel '{channelName}' already exists."); + + var channel = new Channel + { + Id = Guid.NewGuid(), + Name = channelName, + Topic = topic?.Trim(), + IsPublic = isPublic, + CreatedByUserId = creatorUserId, + }; + + db.Channels.Add(channel); + + // Creator automatically becomes a member + db.ChannelMemberships.Add(new ChannelMembership + { + UserId = creatorUserId, + ChannelId = channel.Id, + }); + + await db.SaveChangesAsync(); + + var dto = new ChannelDto(channel.Id, channel.Name, channel.Topic, channel.IsPublic, 0, channel.CreatedAt); + return ChannelOperationResult.Success(dto); + } + + public async Task UpdateTopicAsync( + Guid callerUserId, string channelName, string? topic) + { + channelName = channelName.ToLowerInvariant().Trim(); + + if (topic is not null && topic.Length > ValidationConstants.MaxChannelTopicLength) + return ChannelOperationResult.Fail(ChannelError.ValidationFailed, + $"Topic must not exceed {ValidationConstants.MaxChannelTopicLength} characters."); + + using var scope = _scopeFactory.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + + var dbChannel = await db.Channels.FirstOrDefaultAsync(c => c.Name == channelName); + if (dbChannel is null) + return ChannelOperationResult.Fail(ChannelError.NotFound, $"Channel '{channelName}' does not exist."); + + if (dbChannel.CreatedByUserId != callerUserId) + return ChannelOperationResult.Fail(ChannelError.Forbidden, "Only the channel creator can update the topic."); + + dbChannel.Topic = topic?.Trim(); + await db.SaveChangesAsync(); + + var messageCount = await db.Messages.CountAsync(m => m.ChannelId == dbChannel.Id); + var dto = new ChannelDto(dbChannel.Id, dbChannel.Name, dbChannel.Topic, dbChannel.IsPublic, messageCount, dbChannel.CreatedAt); + return ChannelOperationResult.Success(dto); + } + + public async Task DeleteChannelAsync(Guid callerUserId, string channelName) + { + channelName = channelName.ToLowerInvariant().Trim(); + + if (channelName == HubConstants.DefaultChannel) + return ChannelOperationResult.Fail(ChannelError.Protected, + $"The '{HubConstants.DefaultChannel}' channel cannot be deleted."); + + using var scope = _scopeFactory.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + + var dbChannel = await db.Channels.FirstOrDefaultAsync(c => c.Name == channelName); + if (dbChannel is null) + return ChannelOperationResult.Fail(ChannelError.NotFound, $"Channel '{channelName}' does not exist."); + + var caller = await db.Users.FindAsync(callerUserId); + if (dbChannel.CreatedByUserId != callerUserId && (caller is null || caller.Role < ServerRole.Admin)) + return ChannelOperationResult.Fail(ChannelError.Forbidden, + "Only the channel creator or an admin can delete the channel."); + + db.Channels.Remove(dbChannel); + await db.SaveChangesAsync(); + + var dto = new ChannelDto(dbChannel.Id, dbChannel.Name, dbChannel.Topic, dbChannel.IsPublic, 0, dbChannel.CreatedAt); + return ChannelOperationResult.Success(dto); + } + + public async Task<(string? Topic, bool Exists)> GetChannelTopicAsync(string channelName) + { + channelName = channelName.ToLowerInvariant().Trim(); + + using var scope = _scopeFactory.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + + var channel = await db.Channels.FirstOrDefaultAsync(c => c.Name == channelName); + if (channel is null) return (null, false); + + return (channel.Topic, true); + } + + public async Task> GetChannelListAsync() + { + using var scope = _scopeFactory.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + + var channels = await db.Channels.OrderBy(c => c.Name).ToListAsync(); + + return channels.Select(c => new ChannelListItem( + c.Name, c.Topic, + _presenceTracker.GetOnlineUsersInChannel(c.Name).Count)).ToList(); + } + + public async Task GetChannelByNameAsync(string channelName) + { + channelName = channelName.ToLowerInvariant().Trim(); + + using var scope = _scopeFactory.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + + var c = await db.Channels.FirstOrDefaultAsync(ch => ch.Name == channelName); + if (c is null) return null; + + var messageCount = await db.Messages.CountAsync(m => m.ChannelId == c.Id); + return new ChannelDto(c.Id, c.Name, c.Topic, c.IsPublic, messageCount, c.CreatedAt); + } + + public async Task<(bool Success, string? Error)> EnsureChannelMembershipAsync(Guid userId, string channelName) + { + channelName = channelName.ToLowerInvariant().Trim(); + + if (!ValidationConstants.ChannelNameRegex().IsMatch(channelName)) + return (false, "Invalid channel name. Use 2-100 characters: letters, digits, underscores, or hyphens."); + + using var scope = _scopeFactory.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + + var channel = await db.Channels.FirstOrDefaultAsync(c => c.Name == channelName); + if (channel is null) + { + // Auto-recreate #general if it was somehow removed + if (channelName == HubConstants.DefaultChannel) + { + channel = new Channel + { + Id = Guid.NewGuid(), + Name = HubConstants.DefaultChannel, + Topic = "General discussion", + CreatedByUserId = Guid.Empty, + }; + db.Channels.Add(channel); + await db.SaveChangesAsync(); + _logger.LogWarning("Default channel '{Channel}' was missing and has been recreated", HubConstants.DefaultChannel); + } + else + { + return (false, $"Channel '{channelName}' does not exist. Create it first via the channel list."); + } + } + + var hasMembership = await db.ChannelMemberships + .AnyAsync(m => m.UserId == userId && m.ChannelId == channel.Id); + if (!hasMembership) + { + db.ChannelMemberships.Add(new ChannelMembership + { + UserId = userId, + ChannelId = channel.Id, + }); + await db.SaveChangesAsync(); + } + + return (true, null); + } + + private static async Task EnsureDefaultChannelAsync(EchoHubDbContext db) + { + if (!await db.Channels.AnyAsync(c => c.Name == HubConstants.DefaultChannel)) + { + db.Channels.Add(new Channel + { + Id = Guid.NewGuid(), + Name = HubConstants.DefaultChannel, + Topic = "General discussion", + CreatedByUserId = Guid.Empty, + }); + await db.SaveChangesAsync(); + } + } +} diff --git a/src/EchoHub.Server/Services/ChatService.cs b/src/EchoHub.Server/Services/ChatService.cs index a979e87..b8bb19c 100644 --- a/src/EchoHub.Server/Services/ChatService.cs +++ b/src/EchoHub.Server/Services/ChatService.cs @@ -17,6 +17,7 @@ public class ChatService : IChatService private readonly IEnumerable _broadcasters; private readonly LinkEmbedService _embedService; private readonly IMessageEncryptionService _encryption; + private readonly IChannelService _channelService; private readonly ILogger _logger; public ChatService( @@ -25,6 +26,7 @@ public class ChatService : IChatService IEnumerable broadcasters, LinkEmbedService embedService, IMessageEncryptionService encryption, + IChannelService channelService, ILogger logger) { _scopeFactory = scopeFactory; @@ -32,6 +34,7 @@ public class ChatService : IChatService _broadcasters = broadcasters; _embedService = embedService; _encryption = encryption; + _channelService = channelService; _logger = logger; } @@ -95,47 +98,10 @@ public class ChatService : IChatService { channelName = channelName.ToLowerInvariant().Trim(); - if (!ValidationConstants.ChannelNameRegex().IsMatch(channelName)) - return ([], "Invalid channel name. Use 2-100 characters: letters, digits, underscores, or hyphens."); - - using var scope = _scopeFactory.CreateScope(); - var db = scope.ServiceProvider.GetRequiredService(); - - var channel = await db.Channels.FirstOrDefaultAsync(c => c.Name == channelName); - if (channel is null) - { - // Auto-recreate #general if it was somehow removed - if (channelName == HubConstants.DefaultChannel) - { - channel = new Channel - { - Id = Guid.NewGuid(), - Name = HubConstants.DefaultChannel, - Topic = "General discussion", - CreatedByUserId = Guid.Empty, - }; - db.Channels.Add(channel); - await db.SaveChangesAsync(); - _logger.LogWarning("Default channel '{Channel}' was missing and has been recreated", HubConstants.DefaultChannel); - } - else - { - return ([], $"Channel '{channelName}' does not exist. Create it first via the channel list."); - } - } - - // Persist membership so the channel shows in the user's channel list - var hasMembership = await db.ChannelMemberships - .AnyAsync(m => m.UserId == userId && m.ChannelId == channel.Id); - if (!hasMembership) - { - db.ChannelMemberships.Add(new ChannelMembership - { - UserId = userId, - ChannelId = channel.Id, - }); - await db.SaveChangesAsync(); - } + // Delegate channel validation + membership to ChannelService + var (success, error) = await _channelService.EnsureChannelMembershipAsync(userId, channelName); + if (!success) + return ([], error); var isNewJoin = _presenceTracker.JoinChannel(username, channelName); @@ -145,7 +111,7 @@ public class ChatService : IChatService _logger.LogInformation("{User} joined channel '{Channel}'", username, channelName); } - var history = await GetChannelHistoryInternalAsync(db, channelName, HubConstants.DefaultHistoryCount); + var history = await GetChannelHistoryAsync(channelName, HubConstants.DefaultHistoryCount); return (history, null); } @@ -354,32 +320,6 @@ public class ChatService : IChatService user.StatusMessage, user.Role, user.CreatedAt, user.LastSeenAt); } - public async Task<(string? Topic, bool Exists)> GetChannelTopicAsync(string channelName) - { - channelName = channelName.ToLowerInvariant().Trim(); - - using var scope = _scopeFactory.CreateScope(); - var db = scope.ServiceProvider.GetRequiredService(); - - var channel = await db.Channels.FirstOrDefaultAsync(c => c.Name == channelName); - if (channel is null) return (null, false); - - return (channel.Topic, true); - } - - public async Task> GetChannelListAsync() - { - using var scope = _scopeFactory.CreateScope(); - var db = scope.ServiceProvider.GetRequiredService(); - - var channels = await db.Channels.OrderBy(c => c.Name).ToListAsync(); - - return channels.Select(c => new ChannelListItem( - c.Name, - c.Topic, - _presenceTracker.GetOnlineUsersInChannel(c.Name).Count)).ToList(); - } - public Task> GetChannelsForUserAsync(string username) => Task.FromResult(_presenceTracker.GetChannelsForUser(username)); diff --git a/src/EchoHub.Tests/Irc/IrcCommandHandlerTests.cs b/src/EchoHub.Tests/Irc/IrcCommandHandlerTests.cs index f569055..9aed298 100644 --- a/src/EchoHub.Tests/Irc/IrcCommandHandlerTests.cs +++ b/src/EchoHub.Tests/Irc/IrcCommandHandlerTests.cs @@ -12,10 +12,11 @@ public class IrcCommandHandlerTests { private readonly IrcOptions _options = new() { ServerName = "testserver", Motd = null }; private readonly FakeChatService _chatService = new(); + private readonly FakeChannelService _channelService = new(); private readonly FakeEncryptionService _encryption = new(); private IrcCommandHandler CreateHandler(IrcClientConnection conn) => - new(conn, _options, _chatService, _encryption, NullLogger.Instance); + new(conn, _options, _chatService, _channelService, _encryption, NullLogger.Instance); private async Task> RunAndCapture(string[] inputLines, Action? setup = null) @@ -242,7 +243,7 @@ public class IrcCommandHandlerTests [Fact] public async Task Join_ValidChannel_ConfirmsJoin() { - _chatService.TopicResult = ("Welcome!", true); + _channelService.TopicResult = ("Welcome!", true); var lines = await RunAuthenticated(["JOIN #general"]); @@ -254,7 +255,7 @@ public class IrcCommandHandlerTests [Fact] public async Task Join_SendsTopic() { - _chatService.TopicResult = ("Welcome to general!", true); + _channelService.TopicResult = ("Welcome to general!", true); var lines = await RunAuthenticated(["JOIN #general"]); @@ -264,7 +265,7 @@ public class IrcCommandHandlerTests [Fact] public async Task Join_NoTopic_SendsNoTopicReply() { - _chatService.TopicResult = (null, true); + _channelService.TopicResult = (null, true); var lines = await RunAuthenticated(["JOIN #general"]); @@ -439,7 +440,7 @@ public class IrcCommandHandlerTests [Fact] public async Task Topic_Query_ReturnsTopic() { - _chatService.TopicResult = ("Chat about everything", true); + _channelService.TopicResult = ("Chat about everything", true); var lines = await RunAuthenticated(["TOPIC #general"]); @@ -543,7 +544,7 @@ public class IrcCommandHandlerTests [Fact] public async Task List_ReturnsChannels() { - _chatService.ChannelListToReturn = + _channelService.ChannelListToReturn = [ new("general", "General chat", 5), new("random", null, 2), diff --git a/src/EchoHub.Tests/Irc/TestHelpers.cs b/src/EchoHub.Tests/Irc/TestHelpers.cs index 41b7712..86001d2 100644 --- a/src/EchoHub.Tests/Irc/TestHelpers.cs +++ b/src/EchoHub.Tests/Irc/TestHelpers.cs @@ -158,8 +158,6 @@ internal sealed class FakeChatService : IChatService public string? SendMessageError { get; set; } public (Guid UserId, string Username)? AuthResult { get; set; } public UserProfileDto? ProfileToReturn { get; set; } - public (string? Topic, bool Exists) TopicResult { get; set; } = (null, true); - public List ChannelListToReturn { get; set; } = []; public List ChannelsForUserToReturn { get; set; } = []; public List OnlineUsersToReturn { get; set; } = []; @@ -215,15 +213,48 @@ internal sealed class FakeChatService : IChatService public Task GetUserProfileAsync(string username) => Task.FromResult(ProfileToReturn); - public Task<(string? Topic, bool Exists)> GetChannelTopicAsync(string channelName) => - Task.FromResult(TopicResult); - - public Task> GetChannelListAsync() => - Task.FromResult(ChannelListToReturn); - public Task> GetChannelsForUserAsync(string username) => Task.FromResult(ChannelsForUserToReturn); public Task<(Guid UserId, string Username)?> AuthenticateUserAsync(string username, string password) => Task.FromResult(AuthResult); } + +/// +/// Fake channel service that records method calls and returns pre-configured results. +/// +internal sealed class FakeChannelService : IChannelService +{ + // Configurable results + public (string? Topic, bool Exists) TopicResult { get; set; } = (null, true); + public List ChannelListToReturn { get; set; } = []; + public ChannelDto? ChannelByNameToReturn { get; set; } + public ChannelOperationResult? CreateResult { get; set; } + public ChannelOperationResult? UpdateTopicResult { get; set; } + public ChannelOperationResult? DeleteResult { get; set; } + public (bool Success, string? Error) MembershipResult { get; set; } = (true, null); + + public Task> GetChannelsAsync(Guid userId, int offset, int limit) => + Task.FromResult(new PaginatedResponse([], 0, offset, limit)); + + public Task CreateChannelAsync(Guid creatorUserId, string name, string? topic, bool isPublic) => + Task.FromResult(CreateResult ?? ChannelOperationResult.Fail(ChannelError.ValidationFailed, "Not configured")); + + public Task UpdateTopicAsync(Guid callerUserId, string channelName, string? topic) => + Task.FromResult(UpdateTopicResult ?? ChannelOperationResult.Fail(ChannelError.ValidationFailed, "Not configured")); + + public Task DeleteChannelAsync(Guid callerUserId, string channelName) => + Task.FromResult(DeleteResult ?? ChannelOperationResult.Fail(ChannelError.ValidationFailed, "Not configured")); + + public Task<(string? Topic, bool Exists)> GetChannelTopicAsync(string channelName) => + Task.FromResult(TopicResult); + + public Task> GetChannelListAsync() => + Task.FromResult(ChannelListToReturn); + + public Task GetChannelByNameAsync(string channelName) => + Task.FromResult(ChannelByNameToReturn); + + public Task<(bool Success, string? Error)> EnsureChannelMembershipAsync(Guid userId, string channelName) => + Task.FromResult(MembershipResult); +} From 2cd3bbb570101b87fcba8e93d97184a543f75a02 Mon Sep 17 00:00:00 2001 From: HueByte Date: Sat, 21 Feb 2026 17:22:39 +0100 Subject: [PATCH 2/4] chore: remove submodule NuGet config from CI workflows --- .github/workflows/ci.yml | 13 ++++++++----- .github/workflows/docs.yml | 8 ++++---- .github/workflows/release.yml | 23 +++++++++++------------ 3 files changed, 23 insertions(+), 21 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4f5ef69..bd398d7 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -25,16 +25,16 @@ jobs: with: submodules: recursive + - name: Remove submodule NuGet config + run: rm -f src/Terminal.Gui/nuget.config + - name: Setup .NET 10 uses: actions/setup-dotnet@v4 with: dotnet-version: '10.0.x' - - name: Restore dependencies - run: dotnet restore src/EchoHub.slnx --configfile nuget.config - - name: Check formatting - run: dotnet format src/EchoHub.slnx --no-restore --verify-no-changes --verbosity diagnostic + run: dotnet format src/EchoHub.slnx --verify-no-changes --verbosity diagnostic build-and-test: name: Build & Test @@ -45,6 +45,9 @@ jobs: fetch-depth: 0 submodules: recursive + - name: Remove submodule NuGet config + run: rm -f src/Terminal.Gui/nuget.config + - name: Check for src/ changes id: changes env: @@ -78,7 +81,7 @@ jobs: - name: Restore dependencies if: steps.changes.outputs.src_changed == 'true' - run: dotnet restore src/EchoHub.slnx --configfile nuget.config + run: dotnet restore src/EchoHub.slnx - name: Build if: steps.changes.outputs.src_changed == 'true' diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml index 1f805b2..f05a1bf 100644 --- a/.github/workflows/docs.yml +++ b/.github/workflows/docs.yml @@ -19,6 +19,9 @@ jobs: with: submodules: recursive + - name: Remove submodule NuGet config + run: rm -f src/Terminal.Gui/nuget.config + - name: Setup .NET 10 uses: actions/setup-dotnet@v4 with: @@ -34,11 +37,8 @@ jobs: - name: Restore .NET tools run: dotnet tool restore - - name: Restore dependencies - run: dotnet restore src/EchoHub.slnx --configfile nuget.config - - name: Build solution - run: dotnet build src/EchoHub.slnx --no-restore --configuration Release + run: dotnet build src/EchoHub.slnx --configuration Release - name: Generate documentation run: dotnet docfx docs/docfx.json diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 048d66b..fd21b95 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -18,6 +18,9 @@ jobs: fetch-depth: 0 submodules: recursive + - name: Remove submodule NuGet config + run: rm -f src/Terminal.Gui/nuget.config + - name: Check for src/ changes id: changes env: @@ -56,41 +59,37 @@ jobs: with: dotnet-version: '10.0.x' - - name: Restore dependencies - if: steps.changes.outputs.src_changed == 'true' && steps.check_release.outputs.exists == 'false' - run: dotnet restore src/EchoHub.slnx --configfile nuget.config - - name: Publish Server win-x64 if: steps.changes.outputs.src_changed == 'true' && steps.check_release.outputs.exists == 'false' - run: dotnet publish src/EchoHub.Server/EchoHub.Server.csproj -c Release -r win-x64 --self-contained true --no-restore -o publish/server-win-x64 + run: dotnet publish src/EchoHub.Server/EchoHub.Server.csproj -c Release -r win-x64 --self-contained true -o publish/server-win-x64 - name: Publish Server linux-x64 if: steps.changes.outputs.src_changed == 'true' && steps.check_release.outputs.exists == 'false' - run: dotnet publish src/EchoHub.Server/EchoHub.Server.csproj -c Release -r linux-x64 --self-contained true --no-restore -o publish/server-linux-x64 + run: dotnet publish src/EchoHub.Server/EchoHub.Server.csproj -c Release -r linux-x64 --self-contained true -o publish/server-linux-x64 - name: Publish Server osx-x64 if: steps.changes.outputs.src_changed == 'true' && steps.check_release.outputs.exists == 'false' - run: dotnet publish src/EchoHub.Server/EchoHub.Server.csproj -c Release -r osx-x64 --self-contained true --no-restore -o publish/server-osx-x64 + run: dotnet publish src/EchoHub.Server/EchoHub.Server.csproj -c Release -r osx-x64 --self-contained true -o publish/server-osx-x64 - name: Publish Server osx-arm64 if: steps.changes.outputs.src_changed == 'true' && steps.check_release.outputs.exists == 'false' - run: dotnet publish src/EchoHub.Server/EchoHub.Server.csproj -c Release -r osx-arm64 --self-contained true --no-restore -o publish/server-osx-arm64 + run: dotnet publish src/EchoHub.Server/EchoHub.Server.csproj -c Release -r osx-arm64 --self-contained true -o publish/server-osx-arm64 - name: Publish Client win-x64 if: steps.changes.outputs.src_changed == 'true' && steps.check_release.outputs.exists == 'false' - run: dotnet publish src/EchoHub.Client/EchoHub.Client.csproj -c Release -r win-x64 --self-contained true --no-restore -o publish/client-win-x64 + run: dotnet publish src/EchoHub.Client/EchoHub.Client.csproj -c Release -r win-x64 --self-contained true -o publish/client-win-x64 - name: Publish Client linux-x64 if: steps.changes.outputs.src_changed == 'true' && steps.check_release.outputs.exists == 'false' - run: dotnet publish src/EchoHub.Client/EchoHub.Client.csproj -c Release -r linux-x64 --self-contained true --no-restore -o publish/client-linux-x64 + run: dotnet publish src/EchoHub.Client/EchoHub.Client.csproj -c Release -r linux-x64 --self-contained true -o publish/client-linux-x64 - name: Publish Client osx-x64 if: steps.changes.outputs.src_changed == 'true' && steps.check_release.outputs.exists == 'false' - run: dotnet publish src/EchoHub.Client/EchoHub.Client.csproj -c Release -r osx-x64 --self-contained true --no-restore -o publish/client-osx-x64 + run: dotnet publish src/EchoHub.Client/EchoHub.Client.csproj -c Release -r osx-x64 --self-contained true -o publish/client-osx-x64 - name: Publish Client osx-arm64 if: steps.changes.outputs.src_changed == 'true' && steps.check_release.outputs.exists == 'false' - run: dotnet publish src/EchoHub.Client/EchoHub.Client.csproj -c Release -r osx-arm64 --self-contained true --no-restore -o publish/client-osx-arm64 + run: dotnet publish src/EchoHub.Client/EchoHub.Client.csproj -c Release -r osx-arm64 --self-contained true -o publish/client-osx-arm64 - name: Zip artifacts if: steps.changes.outputs.src_changed == 'true' && steps.check_release.outputs.exists == 'false' From 4930aee615822a4c31899654f6144b5aad6b6994 Mon Sep 17 00:00:00 2001 From: HueByte Date: Sat, 21 Feb 2026 17:23:14 +0100 Subject: [PATCH 3/4] chore: update CI workflows to temporarily remove Terminal.Gui submodule's nuget.config to fix restore issues --- .github/workflows/ci.yml | 2 ++ .github/workflows/docs.yml | 1 + .github/workflows/release.yml | 1 + 3 files changed, 4 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index bd398d7..1d91ad0 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -25,6 +25,7 @@ jobs: with: submodules: recursive + # TEMPORARY: Terminal.Gui submodule's nuget.config breaks restore — remove until PR #4234 is merged - name: Remove submodule NuGet config run: rm -f src/Terminal.Gui/nuget.config @@ -45,6 +46,7 @@ jobs: fetch-depth: 0 submodules: recursive + # TEMPORARY: Terminal.Gui submodule's nuget.config breaks restore — remove until PR #4234 is merged - name: Remove submodule NuGet config run: rm -f src/Terminal.Gui/nuget.config diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml index f05a1bf..cbc2dde 100644 --- a/.github/workflows/docs.yml +++ b/.github/workflows/docs.yml @@ -19,6 +19,7 @@ jobs: with: submodules: recursive + # TEMPORARY: Terminal.Gui submodule's nuget.config breaks restore — remove until PR #4234 is merged - name: Remove submodule NuGet config run: rm -f src/Terminal.Gui/nuget.config diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index fd21b95..1a200d6 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -18,6 +18,7 @@ jobs: fetch-depth: 0 submodules: recursive + # TEMPORARY: Terminal.Gui submodule's nuget.config breaks restore — remove until PR #4234 is merged - name: Remove submodule NuGet config run: rm -f src/Terminal.Gui/nuget.config From dcb8bbc05fe3db3774dcd919c0dabb6874a60665 Mon Sep 17 00:00:00 2001 From: HueByte Date: Sat, 21 Feb 2026 17:26:30 +0100 Subject: [PATCH 4/4] fix: exclude Terminal.Gui from formatting checks in CI workflow --- .github/workflows/ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1d91ad0..0aabca1 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -35,7 +35,7 @@ jobs: dotnet-version: '10.0.x' - name: Check formatting - run: dotnet format src/EchoHub.slnx --verify-no-changes --verbosity diagnostic + run: dotnet format src/EchoHub.slnx --verify-no-changes --verbosity diagnostic --exclude src/Terminal.Gui/ build-and-test: name: Build & Test