diff --git a/.github/instructions/csharp.instructions.md b/.github/instructions/csharp.instructions.md index a3d29f1..669b98f 100644 --- a/.github/instructions/csharp.instructions.md +++ b/.github/instructions/csharp.instructions.md @@ -288,8 +288,6 @@ Demonstrates naming, structure, generics, primary constructors, nullable annotat ```csharp namespace Company.Project.Widgets; -using ItemCache = Dictionary; - /// Defines folding behavior for widgets. public interface IWidget { diff --git a/.vscode/settings.json b/.vscode/settings.json index 0a9bde3..a843abf 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -6,6 +6,7 @@ "/^dotnet --version$/": { "approve": true, "matchCommandLine": true - } + }, + "dotnet build": true } } \ No newline at end of file diff --git a/TalkFolio.slnx b/TalkFolio.slnx index 0c6ce4a..a4f16e1 100644 --- a/TalkFolio.slnx +++ b/TalkFolio.slnx @@ -1,6 +1,11 @@ - + + - + + + + + \ No newline at end of file diff --git a/docs/ADRs.md b/docs/ADRs.md index fc73b6e..510dfb7 100644 --- a/docs/ADRs.md +++ b/docs/ADRs.md @@ -204,6 +204,7 @@ This document consolidates the design decisions reached for TalkFolio. Each entr * Malformed YAML throws `MalformedTalkYamlException`. * Duplicate talk IDs throw `DuplicateTalkIdException`. * Duplicate `(Title, PresentationFamily.Variant)` pairs throw `DuplicateTalkTitleVariantException`. +* `PresentationFamily.Name` does not participate in the duplicate-talk uniqueness key. * These exceptions are logged as load failures and then rethrown so upstream callers can handle each failure type distinctly. * Catalog loads only succeed when all talk files satisfy the repository invariants. diff --git a/docs/TalkSchema.md b/docs/TalkSchema.md index f9ce677..c5063a6 100644 --- a/docs/TalkSchema.md +++ b/docs/TalkSchema.md @@ -42,7 +42,7 @@ Tags: - embeddings - knowledge-graph PresentationFamily: - Id: 8ccdf8b8-fd2c-4d41-9fe0-32fade0f41dc + Name: RAG Deep Dive Variant: Canonical LifecycleStatus: Active ProposalCopyItems: @@ -103,7 +103,7 @@ The current best-fit set of core fields is: * AlternateTitles: list of marketing or branding variants * Category: coarse top-level selection bucket from a controlled list that can expand over time * Tags: topic labels for overlap and CFP matching; free-form strings constrained to alphanumerics and `-` -* PresentationFamily: family membership object with `Id` and `Variant` (for example `Canonical`, `ExecutiveOverview`, `Lightning`, `Workshop`) +* PresentationFamily: family membership object with `Name` and `Variant` (for example `Canonical`, `ExecutiveOverview`, `Lightning`, `Workshop`) * LifecycleStatus: concept-level state * ProposalCopyItems: typed array of inline proposal copy blocks, each with `Type` and `Copy` (`|-` literal block) * TargetAudience: list of audience descriptors drawn from a controlled list that can expand over time @@ -189,6 +189,7 @@ PresentationFamily: * PresentationFamily is not a taxonomy node. * It is a grouping concept, not a category hierarchy. * Family names are treated as stable identifiers for the grouping, not display-only text. +* Catalog duplicate detection keys on Talk `Title` + `PresentationFamily.Variant` only; `PresentationFamily.Name` does not participate in that uniqueness check. * Two talks in the same PresentationFamily should not be co-submitted to the same conference. * TalkCircuit enforces this rule at submission time. * TalkCircuit can find a talk's family members by querying Talks that share its `PresentationFamily.Name`. @@ -329,12 +330,13 @@ The working baseline for the first TalkFolio implementation is: * Talk entity with GUID `Id` and canonical field set * controlled-but-extensible Category list * Tags as free-form, hyphen-safe strings -* presentation family with the Talk owning membership via a nested `PresentationFamily` object (`Id` + `Variant`) +* presentation family with the Talk owning membership via a nested `PresentationFamily` object (`Name` + `Variant`) * concept lifecycle of Ideation | Active | Retired * references to SlideDeckIds and optional public publication references * proposal copy stored inline as `ProposalCopyItems` (typed items with `|-` literal-block copy) * companion material referenced via `RelatedContent` (typed, talk-level, lightweight references) * flexible talk-level flags via `Flags` * unstructured prose limited to `ProposalCopyItems[].Copy`, `IdeationNotes`, `PresentationFamily.Notes`, and `RelatedContent[].Notes` +* duplicate-talk validation keys on `Title` + `PresentationFamily.Variant` only; `PresentationFamily.Name` is not part of that uniqueness rule This gives a clean, minimal schema that matches the domain boundary without pulling in deck-building or submission-state concerns. diff --git a/src/TalkFolio.Api/Program.cs b/src/TalkFolio.Api/Program.cs index 2f42917..1543215 100644 --- a/src/TalkFolio.Api/Program.cs +++ b/src/TalkFolio.Api/Program.cs @@ -1,5 +1,9 @@ namespace TalkFolio.Api; +using TalkFolio.Data.YamlFile; +using TalkFolio.Interfaces; +using TalkFolio.Services; + #pragma warning disable CA1052, CA1515 public partial class Program { @@ -8,10 +12,11 @@ public static WebApplication BuildApp(string[] args) var builder = WebApplication.CreateBuilder(args); builder.Services - .AddOptions() - .BindConfiguration("TalkCatalogRepository"); + .AddOptions() + .BindConfiguration("TalkCatalog"); - builder.Services.AddSingleton(); + builder.Services.AddSingleton(); + builder.Services.AddSingleton(); var app = builder.Build(); @@ -20,13 +25,13 @@ public static WebApplication BuildApp(string[] args) app.MapGet( "/talks", async Task ( - ITalkCatalogRepository repository, + TalkCatalogService service, ILoggerFactory loggerFactory, CancellationToken cancellationToken) => { var logger = loggerFactory.CreateLogger("TalkFolio.Api.TalksEndpoint"); ProgramLog.HandlingGetTalksRequest(logger); - var catalog = await repository.LoadAsync(cancellationToken).ConfigureAwait(false); + var catalog = await service.LoadAsync(cancellationToken).ConfigureAwait(false); ProgramLog.ReturningTalksFromGetTalks(logger, catalog.Talks.Count); if (logger.IsEnabled(LogLevel.Trace)) @@ -46,4 +51,4 @@ public static void Main(string[] args) BuildApp(args).Run(); } } -#pragma warning restore CA1052, CA1515 +#pragma warning restore CA1052, CA1515 \ No newline at end of file diff --git a/src/TalkFolio.Api/TalkFolio.Api.csproj b/src/TalkFolio.Api/TalkFolio.Api.csproj index ed7050d..28f6b55 100644 --- a/src/TalkFolio.Api/TalkFolio.Api.csproj +++ b/src/TalkFolio.Api/TalkFolio.Api.csproj @@ -8,6 +8,7 @@ + - + \ No newline at end of file diff --git a/src/TalkFolio/YamlPresentationFamilyReference.cs b/src/TalkFolio.Data.YamlFile/Serialization/PresentationFamilyReference.cs similarity index 67% rename from src/TalkFolio/YamlPresentationFamilyReference.cs rename to src/TalkFolio.Data.YamlFile/Serialization/PresentationFamilyReference.cs index 79c91bc..c93dad5 100644 --- a/src/TalkFolio/YamlPresentationFamilyReference.cs +++ b/src/TalkFolio.Data.YamlFile/Serialization/PresentationFamilyReference.cs @@ -1,12 +1,12 @@ -namespace TalkFolio; +namespace TalkFolio.Data.YamlFile.Serialization; using System.Diagnostics.CodeAnalysis; /// -/// Raw YAML projection of presentation family data. +/// Raw projection of presentation family data. /// [SuppressMessage("Performance", "CA1812:Avoid uninstantiated internal classes", Justification = "Used by YamlDotNet reflection deserialization.")] -internal sealed class YamlPresentationFamilyReference +internal sealed class PresentationFamilyReference { public string? Name { get; set; } diff --git a/src/TalkFolio/YamlProposalCopyItem.cs b/src/TalkFolio.Data.YamlFile/Serialization/ProposalCopyItem.cs similarity index 68% rename from src/TalkFolio/YamlProposalCopyItem.cs rename to src/TalkFolio.Data.YamlFile/Serialization/ProposalCopyItem.cs index e2c1dad..4aa9d1e 100644 --- a/src/TalkFolio/YamlProposalCopyItem.cs +++ b/src/TalkFolio.Data.YamlFile/Serialization/ProposalCopyItem.cs @@ -1,12 +1,12 @@ -namespace TalkFolio; +namespace TalkFolio.Data.YamlFile.Serialization; using System.Diagnostics.CodeAnalysis; /// -/// Raw YAML projection of proposal copy item data. +/// Raw projection of proposal copy item data. /// [SuppressMessage("Performance", "CA1812:Avoid uninstantiated internal classes", Justification = "Used by YamlDotNet reflection deserialization.")] -internal sealed class YamlProposalCopyItem +internal sealed class ProposalCopyItem { public string? Type { get; set; } diff --git a/src/TalkFolio/YamlPublicPresentationReference.cs b/src/TalkFolio.Data.YamlFile/Serialization/PublicPresentationReference.cs similarity index 68% rename from src/TalkFolio/YamlPublicPresentationReference.cs rename to src/TalkFolio.Data.YamlFile/Serialization/PublicPresentationReference.cs index 6fb94c4..11f33ed 100644 --- a/src/TalkFolio/YamlPublicPresentationReference.cs +++ b/src/TalkFolio.Data.YamlFile/Serialization/PublicPresentationReference.cs @@ -1,12 +1,12 @@ -namespace TalkFolio; +namespace TalkFolio.Data.YamlFile.Serialization; using System.Diagnostics.CodeAnalysis; /// -/// Raw YAML projection of a public presentation reference. +/// Raw projection of a public presentation reference. /// [SuppressMessage("Performance", "CA1812:Avoid uninstantiated internal classes", Justification = "Used by YamlDotNet reflection deserialization.")] -internal sealed class YamlPublicPresentationReference +internal sealed class PublicPresentationReference { public string? Source { get; set; } diff --git a/src/TalkFolio/YamlRelatedContentItem.cs b/src/TalkFolio.Data.YamlFile/Serialization/RelatedContentItem.cs similarity index 73% rename from src/TalkFolio/YamlRelatedContentItem.cs rename to src/TalkFolio.Data.YamlFile/Serialization/RelatedContentItem.cs index ed4f280..7942d08 100644 --- a/src/TalkFolio/YamlRelatedContentItem.cs +++ b/src/TalkFolio.Data.YamlFile/Serialization/RelatedContentItem.cs @@ -1,12 +1,12 @@ -namespace TalkFolio; +namespace TalkFolio.Data.YamlFile.Serialization; using System.Diagnostics.CodeAnalysis; /// -/// Raw YAML projection of related companion content. +/// Raw projection of related companion content. /// [SuppressMessage("Performance", "CA1812:Avoid uninstantiated internal classes", Justification = "Used by YamlDotNet reflection deserialization.")] -internal sealed class YamlRelatedContentItem +internal sealed class RelatedContentItem { public string? Type { get; set; } diff --git a/src/TalkFolio/YamlTalkRecord.cs b/src/TalkFolio.Data.YamlFile/Serialization/TalkRecord.cs similarity index 66% rename from src/TalkFolio/YamlTalkRecord.cs rename to src/TalkFolio.Data.YamlFile/Serialization/TalkRecord.cs index 9a626cb..c79ba81 100644 --- a/src/TalkFolio/YamlTalkRecord.cs +++ b/src/TalkFolio.Data.YamlFile/Serialization/TalkRecord.cs @@ -1,12 +1,12 @@ -namespace TalkFolio; +namespace TalkFolio.Data.YamlFile.Serialization; using System.Diagnostics.CodeAnalysis; /// -/// Raw YAML projection for a talk record. +/// Raw projection for a talk record. /// [SuppressMessage("Performance", "CA1812:Avoid uninstantiated internal classes", Justification = "Used by YamlDotNet reflection deserialization.")] -internal sealed class YamlTalkRecord +internal sealed class TalkRecord { public Guid Id { get; set; } @@ -18,7 +18,7 @@ internal sealed class YamlTalkRecord public List? Tags { get; set; } - public YamlPresentationFamilyReference? PresentationFamily { get; set; } + public PresentationFamilyReference? PresentationFamily { get; set; } public string? LifecycleStatus { get; set; } @@ -28,11 +28,11 @@ internal sealed class YamlTalkRecord public List? SlideDeckIds { get; set; } - public List? ProposalCopyItems { get; set; } + public List? ProposalCopyItems { get; set; } - public List? PublicPresentationReferences { get; set; } + public List? PublicPresentationReferences { get; set; } - public List? RelatedContent { get; set; } + public List? RelatedContent { get; set; } public string? IdeationNotes { get; set; } diff --git a/src/TalkFolio/TalkCatalogRepositoryOptions.cs b/src/TalkFolio.Data.YamlFile/TalkCatalogOptions.cs similarity index 59% rename from src/TalkFolio/TalkCatalogRepositoryOptions.cs rename to src/TalkFolio.Data.YamlFile/TalkCatalogOptions.cs index 76a19c4..96e9a41 100644 --- a/src/TalkFolio/TalkCatalogRepositoryOptions.cs +++ b/src/TalkFolio.Data.YamlFile/TalkCatalogOptions.cs @@ -1,9 +1,9 @@ -namespace TalkFolio; +namespace TalkFolio.Data.YamlFile; /// -/// Configures the file-backed repository used to read TalkFolio data. +/// Configures the repository used to read TalkFolio data. /// -public sealed class TalkCatalogRepositoryOptions +public sealed class TalkCatalogOptions { /// /// Gets or sets the root directory that contains the repository data. diff --git a/src/TalkFolio/FileSystemTalkCatalogRepository.cs b/src/TalkFolio.Data.YamlFile/TalkCatalogRepository.cs similarity index 65% rename from src/TalkFolio/FileSystemTalkCatalogRepository.cs rename to src/TalkFolio.Data.YamlFile/TalkCatalogRepository.cs index 5aa393b..facf02f 100644 --- a/src/TalkFolio/FileSystemTalkCatalogRepository.cs +++ b/src/TalkFolio.Data.YamlFile/TalkCatalogRepository.cs @@ -1,60 +1,62 @@ -namespace TalkFolio; +namespace TalkFolio.Data.YamlFile; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging.Abstractions; using Microsoft.Extensions.Options; +using TalkFolio.Data.YamlFile.Serialization; +using TalkFolio.Interfaces; using YamlDotNet.Core; using YamlDotNet.Serialization; /// -/// Reads the TalkFolio catalog from a file-based YAML data source. +/// Reads the TalkFolio catalog from a file-based data source. /// -public sealed class FileSystemTalkCatalogRepository( - IOptions options, - ILogger? logger = null) : ITalkCatalogRepository +public sealed class TalkCatalogRepository( + IOptions options, + ILogger? logger = null) : ITalkCatalogRepository { private static readonly IDeserializer Deserializer = new DeserializerBuilder() .IgnoreUnmatchedProperties() .Build(); - private readonly ILogger _logger = logger ?? NullLogger.Instance; - private readonly IOptions _options = options ?? throw new ArgumentNullException(nameof(options)); + private readonly ILogger _logger = logger ?? NullLogger.Instance; + private readonly IOptions _options = options ?? throw new ArgumentNullException(nameof(options)); /// - public async Task LoadAsync(CancellationToken cancellationToken = default) + public async Task LoadAsync(CancellationToken cancellationToken = default) { - FileSystemTalkCatalogRepositoryLog.LoadingCatalog(_logger); + TalkCatalogRepositoryLog.LoadingCatalog(_logger); var dataRoot = _options.Value.DataRoot; if (string.IsNullOrWhiteSpace(dataRoot)) { - FileSystemTalkCatalogRepositoryLog.LoadingCatalogFailedBecauseDataRootNotConfigured(_logger); + TalkCatalogRepositoryLog.LoadingCatalogFailedBecauseDataRootNotConfigured(_logger); throw new InvalidOperationException("The repository data root is not configured."); } if (!Directory.Exists(dataRoot)) { - FileSystemTalkCatalogRepositoryLog.LoadingCatalogFailedBecauseDataRootDoesNotExist(_logger, dataRoot); + TalkCatalogRepositoryLog.LoadingCatalogFailedBecauseDataRootDoesNotExist(_logger, dataRoot); throw new DirectoryNotFoundException($"The TalkFolio data root '{dataRoot}' does not exist."); } var talksDirectory = Path.Combine(dataRoot, "talks"); - FileSystemTalkCatalogRepositoryLog.LoadingTalksFrom(_logger, talksDirectory); + TalkCatalogRepositoryLog.LoadingTalksFrom(_logger, talksDirectory); var talks = await LoadTalksAsync(talksDirectory, cancellationToken).ConfigureAwait(false); - FileSystemTalkCatalogRepositoryLog.LoadedCatalog(_logger, talks.Count); + TalkCatalogRepositoryLog.LoadedCatalog(_logger, talks.Count); - return new TalkCatalog(talks); + return new TalkFolio.Entities.TalkCatalog(talks); } - private async Task> LoadTalksAsync(string talksDirectory, CancellationToken cancellationToken) + private async Task> LoadTalksAsync(string talksDirectory, CancellationToken cancellationToken) { if (!Directory.Exists(talksDirectory)) { - FileSystemTalkCatalogRepositoryLog.TalksDirectoryDoesNotExist(_logger, talksDirectory); + TalkCatalogRepositoryLog.TalksDirectoryDoesNotExist(_logger, talksDirectory); return []; } - var talks = new List(); + var talks = new List(); var seenTalkIds = new Dictionary(); var seenTitleVariants = new Dictionary(); var files = Directory.EnumerateFiles(talksDirectory, "*.*", SearchOption.TopDirectoryOnly) @@ -64,16 +66,23 @@ private async Task> LoadTalksAsync(string talksDirecto foreach (var file in files) { cancellationToken.ThrowIfCancellationRequested(); - FileSystemTalkCatalogRepositoryLog.ReadingTalkFile(_logger, file); + TalkCatalogRepositoryLog.ReadingTalkFile(_logger, file); var yaml = await File.ReadAllTextAsync(file, cancellationToken).ConfigureAwait(false); var payload = DeserializeTalk(yaml, file); - FileSystemTalkCatalogRepositoryLog.DeserializedTalkPayload(_logger, payload.Id, payload.Title, file); + TalkCatalogRepositoryLog.DeserializedTalkPayload(_logger, payload.Id, payload.Title, file); + + if (payload.Id == Guid.Empty) + { + var missingTalkIdException = MissingTalkIdException.ForFilePath(file); + TalkCatalogRepositoryLog.TalkFileMissingRequiredId(_logger, missingTalkIdException, file); + throw missingTalkIdException; + } if (seenTalkIds.TryGetValue(payload.Id, out var firstTalkIdFilePath)) { var duplicateIdException = new DuplicateTalkIdException(payload.Id, firstTalkIdFilePath, file); - FileSystemTalkCatalogRepositoryLog.DuplicateTalkId( + TalkCatalogRepositoryLog.DuplicateTalkId( _logger, duplicateIdException, payload.Id, @@ -91,7 +100,7 @@ private async Task> LoadTalksAsync(string talksDirecto variant, firstTitleVariantFilePath, file); - FileSystemTalkCatalogRepositoryLog.DuplicateTalkTitleVariant( + TalkCatalogRepositoryLog.DuplicateTalkTitleVariant( _logger, duplicateTitleVariantException, payload.Title, @@ -104,15 +113,15 @@ private async Task> LoadTalksAsync(string talksDirecto seenTalkIds.Add(payload.Id, file); seenTitleVariants.Add(titleVariantKey, file); talks.Add(MapTalk(payload)); - FileSystemTalkCatalogRepositoryLog.MappedTalkRecord(_logger, payload.Id, file); + TalkCatalogRepositoryLog.MappedTalk(_logger, payload.Id, file); } return talks.AsReadOnly(); } - private static TalkRecord MapTalk(YamlTalkRecord source) + private static TalkFolio.Entities.Talk MapTalk(TalkRecord source) { - return new TalkRecord( + return new TalkFolio.Entities.Talk( Id: source.Id, Title: source.Title, AlternateTitles: source.AlternateTitles ?? [], @@ -120,20 +129,20 @@ private static TalkRecord MapTalk(YamlTalkRecord source) Tags: source.Tags ?? [], LifecycleStatus: source.LifecycleStatus ?? string.Empty, TargetAudience: source.TargetAudience ?? [], - PresentationFamily: source.PresentationFamily is null ? null : new PresentationFamilyReference( + PresentationFamily: source.PresentationFamily is null ? null : new TalkFolio.Entities.PresentationFamily( source.PresentationFamily.Name ?? string.Empty, source.PresentationFamily.Variant ?? string.Empty), SlideDeckIds: source.SlideDeckIds ?? [], ProposalCopyItems: source.ProposalCopyItems is null ? [] : source.ProposalCopyItems - .Select(static item => new ProposalCopyItem(item.Type ?? string.Empty, item.Copy ?? string.Empty)) + .Select(static item => new TalkFolio.Entities.ProposalCopyItem(item.Type ?? string.Empty, item.Copy ?? string.Empty)) .ToList() .AsReadOnly(), PublicPresentationReferences: source.PublicPresentationReferences is null ? [] : source.PublicPresentationReferences - .Select(static item => new PublicPresentationReference( + .Select(static item => new TalkFolio.Entities.PublicPresentationReference( item.Source ?? string.Empty, item.Url, item.PublicId)) @@ -142,7 +151,7 @@ private static TalkRecord MapTalk(YamlTalkRecord source) RelatedContent: source.RelatedContent is null ? [] : source.RelatedContent - .Select(static item => new RelatedContentItem( + .Select(static item => new TalkFolio.Entities.RelatedContentItem( item.Type ?? string.Empty, item.Title ?? string.Empty, item.Url, @@ -155,26 +164,26 @@ private static TalkRecord MapTalk(YamlTalkRecord source) UpdatedAt: source.UpdatedAt); } - private YamlTalkRecord DeserializeTalk(string yaml, string filePath) + private TalkRecord DeserializeTalk(string yaml, string filePath) { try { - return Deserializer.Deserialize(yaml) + return Deserializer.Deserialize(yaml) ?? throw new InvalidOperationException($"Talk YAML file '{filePath}' did not produce a talk record."); } catch (YamlException ex) { var malformedTalkYamlException = MalformedTalkYamlException.ForFilePath(filePath, ex); - FileSystemTalkCatalogRepositoryLog.TalkFileMalformed(_logger, malformedTalkYamlException, filePath); + TalkCatalogRepositoryLog.TalkFileMalformed(_logger, malformedTalkYamlException, filePath); throw malformedTalkYamlException; } catch (InvalidOperationException ex) { var malformedTalkYamlException = MalformedTalkYamlException.ForFilePath(filePath, ex); - FileSystemTalkCatalogRepositoryLog.TalkFileCouldNotBeDeserialized(_logger, malformedTalkYamlException, filePath); + TalkCatalogRepositoryLog.TalkFileCouldNotBeDeserialized(_logger, malformedTalkYamlException, filePath); throw malformedTalkYamlException; } } private readonly record struct TalkTitleVariantKey(string Title, string Variant); -} +} \ No newline at end of file diff --git a/src/TalkFolio/FileSystemTalkCatalogRepository.Logging.cs b/src/TalkFolio.Data.YamlFile/TalkCatalogRepositoryLog.cs similarity index 86% rename from src/TalkFolio/FileSystemTalkCatalogRepository.Logging.cs rename to src/TalkFolio.Data.YamlFile/TalkCatalogRepositoryLog.cs index 94ab9d8..fbc5bf0 100644 --- a/src/TalkFolio/FileSystemTalkCatalogRepository.Logging.cs +++ b/src/TalkFolio.Data.YamlFile/TalkCatalogRepositoryLog.cs @@ -1,8 +1,8 @@ -namespace TalkFolio; +namespace TalkFolio.Data.YamlFile; using Microsoft.Extensions.Logging; -internal static partial class FileSystemTalkCatalogRepositoryLog +internal static partial class TalkCatalogRepositoryLog { [LoggerMessage(EventId = 1, Level = LogLevel.Information, Message = "Loading TalkFolio catalog.")] public static partial void LoadingCatalog(ILogger logger); @@ -28,8 +28,8 @@ internal static partial class FileSystemTalkCatalogRepositoryLog [LoggerMessage(EventId = 8, Level = LogLevel.Trace, Message = "Deserialized talk payload {TalkId} ({TalkTitle}) from {FilePath}.")] public static partial void DeserializedTalkPayload(ILogger logger, Guid talkId, string talkTitle, string filePath); - [LoggerMessage(EventId = 9, Level = LogLevel.Trace, Message = "Mapped talk record {TalkId} from {FilePath}.")] - public static partial void MappedTalkRecord(ILogger logger, Guid talkId, string filePath); + [LoggerMessage(EventId = 9, Level = LogLevel.Trace, Message = "Mapped talk {TalkId} from {FilePath}.")] + public static partial void MappedTalk(ILogger logger, Guid talkId, string filePath); [LoggerMessage(EventId = 10, Level = LogLevel.Error, Message = "Catalog load failed because talk file {FilePath} contains malformed YAML.")] public static partial void TalkFileMalformed(ILogger logger, Exception exception, string filePath); @@ -42,4 +42,7 @@ internal static partial class FileSystemTalkCatalogRepositoryLog [LoggerMessage(EventId = 13, Level = LogLevel.Error, Message = "Catalog load failed because duplicate talk title and variant were found for Title '{Title}' and Variant '{Variant}' in {DuplicateFilePath}. First seen in {FirstFilePath}.")] public static partial void DuplicateTalkTitleVariant(ILogger logger, Exception exception, string title, string variant, string duplicateFilePath, string firstFilePath); -} + + [LoggerMessage(EventId = 14, Level = LogLevel.Error, Message = "Catalog load failed because talk file {FilePath} is missing required Id.")] + public static partial void TalkFileMissingRequiredId(ILogger logger, Exception exception, string filePath); +} \ No newline at end of file diff --git a/src/TalkFolio.Data.YamlFile/TalkFolio.Data.YamlFile.csproj b/src/TalkFolio.Data.YamlFile/TalkFolio.Data.YamlFile.csproj new file mode 100644 index 0000000..b4d38e4 --- /dev/null +++ b/src/TalkFolio.Data.YamlFile/TalkFolio.Data.YamlFile.csproj @@ -0,0 +1,19 @@ + + + + net10.0 + enable + enable + + + + + + + + + + + + + \ No newline at end of file diff --git a/src/TalkFolio/PresentationFamilyReference.cs b/src/TalkFolio/Entities/PresentationFamily.cs similarity index 53% rename from src/TalkFolio/PresentationFamilyReference.cs rename to src/TalkFolio/Entities/PresentationFamily.cs index 2953592..b204fac 100644 --- a/src/TalkFolio/PresentationFamilyReference.cs +++ b/src/TalkFolio/Entities/PresentationFamily.cs @@ -1,8 +1,8 @@ -namespace TalkFolio; +namespace TalkFolio.Entities; /// -/// Represents the family relationship for a talk within the canonical model. +/// Represents the presentation family for a talk within the canonical model. /// /// The stable family name the talk belongs to. /// The talk's variant within the family. -public sealed record PresentationFamilyReference(string Name, string Variant); +public sealed record PresentationFamily(string Name, string Variant); diff --git a/src/TalkFolio/ProposalCopyItem.cs b/src/TalkFolio/Entities/ProposalCopyItem.cs similarity index 90% rename from src/TalkFolio/ProposalCopyItem.cs rename to src/TalkFolio/Entities/ProposalCopyItem.cs index 2b85c2b..14b84ab 100644 --- a/src/TalkFolio/ProposalCopyItem.cs +++ b/src/TalkFolio/Entities/ProposalCopyItem.cs @@ -1,4 +1,4 @@ -namespace TalkFolio; +namespace TalkFolio.Entities; /// /// Represents typed proposal copy attached to a talk. diff --git a/src/TalkFolio/PublicPresentationReference.cs b/src/TalkFolio/Entities/PublicPresentationReference.cs similarity index 86% rename from src/TalkFolio/PublicPresentationReference.cs rename to src/TalkFolio/Entities/PublicPresentationReference.cs index 6ef3276..611202b 100644 --- a/src/TalkFolio/PublicPresentationReference.cs +++ b/src/TalkFolio/Entities/PublicPresentationReference.cs @@ -1,4 +1,4 @@ -namespace TalkFolio; +namespace TalkFolio.Entities; /// /// Represents a public presentation reference for a talk. @@ -8,4 +8,4 @@ namespace TalkFolio; /// The public identifier used by the source. #pragma warning disable CA1054, CA1056 public sealed record PublicPresentationReference(string Source, string? Url, string? PublicId); -#pragma warning restore CA1054, CA1056 +#pragma warning restore CA1054, CA1056 \ No newline at end of file diff --git a/src/TalkFolio/RelatedContentItem.cs b/src/TalkFolio/Entities/RelatedContentItem.cs similarity index 94% rename from src/TalkFolio/RelatedContentItem.cs rename to src/TalkFolio/Entities/RelatedContentItem.cs index bbfe09a..47b8cb9 100644 --- a/src/TalkFolio/RelatedContentItem.cs +++ b/src/TalkFolio/Entities/RelatedContentItem.cs @@ -1,4 +1,4 @@ -namespace TalkFolio; +namespace TalkFolio.Entities; /// /// Represents lightweight companion material related to a talk. diff --git a/src/TalkFolio/TalkRecord.cs b/src/TalkFolio/Entities/Talk.cs similarity index 89% rename from src/TalkFolio/TalkRecord.cs rename to src/TalkFolio/Entities/Talk.cs index 09d533d..b0cf66c 100644 --- a/src/TalkFolio/TalkRecord.cs +++ b/src/TalkFolio/Entities/Talk.cs @@ -1,7 +1,7 @@ -namespace TalkFolio; +namespace TalkFolio.Entities; /// -/// Represents a canonical Talk record in the TalkFolio read model. +/// Represents a canonical Talk in the TalkFolio read model. /// /// The unique identifier for the talk. /// The title of the talk. @@ -19,7 +19,7 @@ namespace TalkFolio; /// Optional ideation notes for the talk. /// The date the talk record was created. /// The date the talk record was last updated. -public sealed record TalkRecord( +public sealed record Talk( Guid Id, string Title, IReadOnlyList AlternateTitles, @@ -27,7 +27,7 @@ public sealed record TalkRecord( IReadOnlyList Tags, string LifecycleStatus, IReadOnlyList TargetAudience, - PresentationFamilyReference? PresentationFamily, + PresentationFamily? PresentationFamily, IReadOnlyList SlideDeckIds, IReadOnlyList ProposalCopyItems, IReadOnlyList PublicPresentationReferences, @@ -35,4 +35,4 @@ public sealed record TalkRecord( IReadOnlyDictionary? Flags, string? IdeationNotes, DateTimeOffset? CreatedAt, - DateTimeOffset? UpdatedAt); + DateTimeOffset? UpdatedAt); \ No newline at end of file diff --git a/src/TalkFolio/TalkCatalog.cs b/src/TalkFolio/Entities/TalkCatalog.cs similarity index 65% rename from src/TalkFolio/TalkCatalog.cs rename to src/TalkFolio/Entities/TalkCatalog.cs index 83c4e1f..9f15ffc 100644 --- a/src/TalkFolio/TalkCatalog.cs +++ b/src/TalkFolio/Entities/TalkCatalog.cs @@ -1,7 +1,7 @@ -namespace TalkFolio; +namespace TalkFolio.Entities; /// /// Represents the complete TalkFolio catalog returned by the repository. /// /// The talks managed by the catalog. -public sealed record TalkCatalog(IReadOnlyList Talks); +public sealed record TalkCatalog(IReadOnlyList Talks); \ No newline at end of file diff --git a/src/TalkFolio/DuplicateTalkIdException.cs b/src/TalkFolio/Exceptions/DuplicateTalkIdException.cs similarity index 100% rename from src/TalkFolio/DuplicateTalkIdException.cs rename to src/TalkFolio/Exceptions/DuplicateTalkIdException.cs diff --git a/src/TalkFolio/DuplicateTalkTitleVariantException.cs b/src/TalkFolio/Exceptions/DuplicateTalkTitleVariantException.cs similarity index 100% rename from src/TalkFolio/DuplicateTalkTitleVariantException.cs rename to src/TalkFolio/Exceptions/DuplicateTalkTitleVariantException.cs diff --git a/src/TalkFolio/MalformedTalkYamlException.cs b/src/TalkFolio/Exceptions/MalformedTalkYamlException.cs similarity index 100% rename from src/TalkFolio/MalformedTalkYamlException.cs rename to src/TalkFolio/Exceptions/MalformedTalkYamlException.cs diff --git a/src/TalkFolio/Exceptions/MissingTalkIdException.cs b/src/TalkFolio/Exceptions/MissingTalkIdException.cs new file mode 100644 index 0000000..2eed49e --- /dev/null +++ b/src/TalkFolio/Exceptions/MissingTalkIdException.cs @@ -0,0 +1,50 @@ +namespace TalkFolio; + +/// +/// Represents a talk record that did not supply a required identifier. +/// +public sealed class MissingTalkIdException : TalkCatalogLoadException +{ + /// + /// Initializes a new instance of the class. + /// + public MissingTalkIdException() + : this("Talk file is missing a required Id value.") + { + } + + /// + /// Initializes a new instance of the class. + /// + /// The error message. + public MissingTalkIdException(string message) + : this(message, new InvalidOperationException(message)) + { + } + + /// + /// Initializes a new instance of the class. + /// + /// The error message. + /// The inner exception. + public MissingTalkIdException(string message, Exception innerException) + : base(message, innerException) + { + } + + /// + /// Creates a missing talk identifier exception for a specific file path. + /// + /// The talk file path that is missing an identifier. + /// The created exception. + public static MissingTalkIdException ForFilePath(string filePath) + => new($"Talk file '{filePath}' is missing a required Id value.") + { + FilePath = filePath, + }; + + /// + /// Gets the talk file path that is missing the required identifier. + /// + public string FilePath { get; private set; } = string.Empty; +} diff --git a/src/TalkFolio/TalkCatalogLoadException.cs b/src/TalkFolio/Exceptions/TalkCatalogLoadException.cs similarity index 100% rename from src/TalkFolio/TalkCatalogLoadException.cs rename to src/TalkFolio/Exceptions/TalkCatalogLoadException.cs diff --git a/src/TalkFolio/ITalkCatalogRepository.cs b/src/TalkFolio/Interfaces/ITalkCatalogRepository.cs similarity index 86% rename from src/TalkFolio/ITalkCatalogRepository.cs rename to src/TalkFolio/Interfaces/ITalkCatalogRepository.cs index 469aa14..9d1d882 100644 --- a/src/TalkFolio/ITalkCatalogRepository.cs +++ b/src/TalkFolio/Interfaces/ITalkCatalogRepository.cs @@ -1,7 +1,9 @@ -namespace TalkFolio; +namespace TalkFolio.Interfaces; + +using TalkFolio.Entities; /// -/// Provides a repository that loads the TalkFolio catalog from YAML files on disk. +/// Provides a repository that loads the TalkFolio catalog from a data source. /// public interface ITalkCatalogRepository { @@ -11,4 +13,4 @@ public interface ITalkCatalogRepository /// A token that can be used to cancel the load operation. /// The loaded catalog. Task LoadAsync(CancellationToken cancellationToken = default); -} +} \ No newline at end of file diff --git a/src/TalkFolio/Services/TalkCatalogService.cs b/src/TalkFolio/Services/TalkCatalogService.cs new file mode 100644 index 0000000..b36ea5b --- /dev/null +++ b/src/TalkFolio/Services/TalkCatalogService.cs @@ -0,0 +1,18 @@ +namespace TalkFolio.Services; + +using TalkFolio.Entities; +using TalkFolio.Interfaces; + +/// +/// Provides catalog operations for use within the TalkFolio domain. +/// +public sealed class TalkCatalogService(ITalkCatalogRepository repository) +{ + /// + /// Loads the canonical TalkFolio catalog. + /// + /// A token that can be used to cancel the load operation. + /// The loaded catalog. + public Task LoadAsync(CancellationToken cancellationToken = default) + => repository.LoadAsync(cancellationToken); +} \ No newline at end of file diff --git a/src/TalkFolio/TalkFolio.csproj b/src/TalkFolio/TalkFolio.csproj index f564cd1..8d1234e 100644 --- a/src/TalkFolio/TalkFolio.csproj +++ b/src/TalkFolio/TalkFolio.csproj @@ -1,4 +1,4 @@ - + net10.0 @@ -8,8 +8,6 @@ - - - + \ No newline at end of file diff --git a/src/TalkFolio.Tests/FileSystemTalkCatalogRepository_LoadAsync_Should.cs b/tst/TalkFolio.Data.YamlFile.Tests/TalkCatalogRepository_LoadAsync_Should.cs similarity index 75% rename from src/TalkFolio.Tests/FileSystemTalkCatalogRepository_LoadAsync_Should.cs rename to tst/TalkFolio.Data.YamlFile.Tests/TalkCatalogRepository_LoadAsync_Should.cs index 77a4002..77dff48 100644 --- a/src/TalkFolio.Tests/FileSystemTalkCatalogRepository_LoadAsync_Should.cs +++ b/tst/TalkFolio.Data.YamlFile.Tests/TalkCatalogRepository_LoadAsync_Should.cs @@ -1,14 +1,15 @@ -namespace TalkFolio.Tests; +namespace TalkFolio.Data.YamlFile.Tests; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; +using TalkFolio.Data.YamlFile; using NSubstitute; -public sealed class FileSystemTalkCatalogRepository_LoadAsync_Should : IDisposable +public sealed class TalkCatalogRepository_LoadAsync_Should : IDisposable { private readonly string _dataRoot; - public FileSystemTalkCatalogRepository_LoadAsync_Should() + public TalkCatalogRepository_LoadAsync_Should() { _dataRoot = Path.Combine(Path.GetTempPath(), $"talkfolio-tests-{Guid.NewGuid():N}"); Directory.CreateDirectory(_dataRoot); @@ -19,8 +20,8 @@ public async Task ReturnCanonicalCatalog_WhenYamlFilesExist() { // Arrange var repositoryRoot = await CreateRepositoryRoot(); - var target = new FileSystemTalkCatalogRepository( - Options.Create(new TalkCatalogRepositoryOptions + var target = new TalkCatalogRepository( + Options.Create(new TalkCatalogOptions { DataRoot = repositoryRoot, })); @@ -59,6 +60,49 @@ public async Task ReturnCanonicalCatalog_WhenYamlFilesExist() Assert.Equal("great-cornholio-tp", publicPresentation.PublicId); } + [Fact] + public async Task ThrowInvalidOperationException_WhenDataRootIsNotConfigured() + { + var target = new TalkCatalogRepository(Options.Create(new TalkCatalogOptions())); + + var actual = await Assert.ThrowsAsync( + () => target.LoadAsync(CancellationToken.None)); + + Assert.Equal("The repository data root is not configured.", actual.Message); + } + + [Fact] + public async Task ReturnEmptyCatalog_WhenTalksDirectoryIsMissing() + { + var repositoryRoot = Path.Combine(_dataRoot, "no-talks-directory"); + Directory.CreateDirectory(repositoryRoot); + var target = new TalkCatalogRepository( + Options.Create(new TalkCatalogOptions + { + DataRoot = repositoryRoot, + })); + + var actual = await target.LoadAsync(CancellationToken.None); + + Assert.Empty(actual.Talks); + } + + [Fact] + public async Task ThrowDirectoryNotFoundException_WhenDataRootDoesNotExist() + { + var repositoryRoot = Path.Combine(_dataRoot, "missing-root"); + var target = new TalkCatalogRepository( + Options.Create(new TalkCatalogOptions + { + DataRoot = repositoryRoot, + })); + + var actual = await Assert.ThrowsAsync( + () => target.LoadAsync(CancellationToken.None)); + + Assert.Contains(repositoryRoot, actual.Message, StringComparison.Ordinal); + } + [Fact] public async Task ThrowDuplicateTalkIdException_WhenLaterFilesResolveToSameId() { @@ -100,9 +144,9 @@ await File.WriteAllTextAsync( try { - var logger = Substitute.For>(); - var target = new FileSystemTalkCatalogRepository( - Options.Create(new TalkCatalogRepositoryOptions + var logger = Substitute.For>(); + var target = new TalkCatalogRepository( + Options.Create(new TalkCatalogOptions { DataRoot = repositoryRoot, }), @@ -166,8 +210,8 @@ await File.WriteAllTextAsync( try { - var target = new FileSystemTalkCatalogRepository( - Options.Create(new TalkCatalogRepositoryOptions + var target = new TalkCatalogRepository( + Options.Create(new TalkCatalogOptions { DataRoot = repositoryRoot, })); @@ -218,8 +262,8 @@ await File.WriteAllTextAsync( try { - var target = new FileSystemTalkCatalogRepository( - Options.Create(new TalkCatalogRepositoryOptions + var target = new TalkCatalogRepository( + Options.Create(new TalkCatalogOptions { DataRoot = repositoryRoot, })); @@ -241,14 +285,60 @@ await File.WriteAllTextAsync( } } + [Fact] + public async Task ThrowMissingTalkIdException_WhenTalkRecordDoesNotSupplyId() + { + // Arrange + var repositoryRoot = Path.Combine(Path.GetTempPath(), $"talkfolio-missing-id-{Guid.NewGuid():N}"); + Directory.CreateDirectory(repositoryRoot); + var talksDirectory = Directory.CreateDirectory(Path.Combine(repositoryRoot, "talks")); + var talkFilePath = Path.Combine(talksDirectory.FullName, "missing-id.yaml"); + + await File.WriteAllTextAsync( + talkFilePath, + """ + Title: Missing Identifier Talk + Category: Leadership & Community + Tags: + - missing-id + PresentationFamily: + Name: Missing Identifier Family + Variant: Canonical + LifecycleStatus: Active + """); + + try + { + var target = new TalkCatalogRepository( + Options.Create(new TalkCatalogOptions + { + DataRoot = repositoryRoot, + })); + + // Act + var actual = await Assert.ThrowsAsync( + () => target.LoadAsync(CancellationToken.None)); + + // Assert + Assert.Equal(talkFilePath, actual.FilePath); + } + finally + { + if (Directory.Exists(repositoryRoot)) + { + Directory.Delete(repositoryRoot, recursive: true); + } + } + } + [Fact] public async Task EmitBoundaryLogs_WhenLoadingCatalog() { // Arrange var repositoryRoot = await CreateRepositoryRoot(); - var logger = new CollectingLogger(); - var target = new FileSystemTalkCatalogRepository( - Options.Create(new TalkCatalogRepositoryOptions + var logger = new CollectingLogger(); + var target = new TalkCatalogRepository( + Options.Create(new TalkCatalogOptions { DataRoot = repositoryRoot, }), @@ -361,5 +451,3 @@ Consider a sequel on identifying sources of caffeine. return repositoryRoot; } } - - diff --git a/tst/TalkFolio.Data.YamlFile.Tests/TalkFolio.Data.YamlFile.Tests.csproj b/tst/TalkFolio.Data.YamlFile.Tests/TalkFolio.Data.YamlFile.Tests.csproj new file mode 100644 index 0000000..8ae9e81 --- /dev/null +++ b/tst/TalkFolio.Data.YamlFile.Tests/TalkFolio.Data.YamlFile.Tests.csproj @@ -0,0 +1,28 @@ + + + + net10.0 + enable + enable + false + $(NoWarn);CA1707;CA2007 + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/tst/TalkFolio.Tests/TalkCatalogService_Should.cs b/tst/TalkFolio.Tests/TalkCatalogService_Should.cs new file mode 100644 index 0000000..7a87b17 --- /dev/null +++ b/tst/TalkFolio.Tests/TalkCatalogService_Should.cs @@ -0,0 +1,22 @@ +namespace TalkFolio.Tests; + +using NSubstitute; +using TalkFolio.Entities; +using TalkFolio.Interfaces; +using TalkFolio.Services; + +public sealed class TalkCatalogService_LoadAsync_Should +{ + [Fact] + public async Task ReturnRepositoryCatalog_WhenLoading() + { + var repository = Substitute.For(); + var expected = new TalkCatalog([]); + repository.LoadAsync(Arg.Any()).Returns(expected); + var target = new TalkCatalogService(repository); + + var actual = await target.LoadAsync(CancellationToken.None); + + Assert.Same(expected, actual); + } +} diff --git a/src/TalkFolio.Tests/TalkFolio.Tests.csproj b/tst/TalkFolio.Tests/TalkFolio.Tests.csproj similarity index 84% rename from src/TalkFolio.Tests/TalkFolio.Tests.csproj rename to tst/TalkFolio.Tests/TalkFolio.Tests.csproj index 4faca3b..1e5914a 100644 --- a/src/TalkFolio.Tests/TalkFolio.Tests.csproj +++ b/tst/TalkFolio.Tests/TalkFolio.Tests.csproj @@ -22,8 +22,8 @@ - - + + diff --git a/src/TalkFolio.Tests/TalksEndpoint_GetTalks_Should.cs b/tst/TalkFolio.Tests/TalksEndpoint_GetTalks_Should.cs similarity index 96% rename from src/TalkFolio.Tests/TalksEndpoint_GetTalks_Should.cs rename to tst/TalkFolio.Tests/TalksEndpoint_GetTalks_Should.cs index e14c0c4..567c060 100644 --- a/src/TalkFolio.Tests/TalksEndpoint_GetTalks_Should.cs +++ b/tst/TalkFolio.Tests/TalksEndpoint_GetTalks_Should.cs @@ -3,6 +3,7 @@ namespace TalkFolio.Tests; using System.Net.Http.Json; using Microsoft.AspNetCore.Mvc.Testing; using Microsoft.Extensions.Configuration; +using TalkFolio.Entities; public sealed class TalksEndpoint_GetTalks_Should : IDisposable { @@ -26,7 +27,7 @@ public async Task ReturnCanonicalTalks_WhenRepositoryContainsTalkData() { configBuilder.AddInMemoryCollection(new Dictionary { - ["TalkCatalogRepository:DataRoot"] = repositoryRoot, + ["TalkCatalog:DataRoot"] = repositoryRoot, }); }); }); @@ -37,7 +38,7 @@ public async Task ReturnCanonicalTalks_WhenRepositoryContainsTalkData() // Assert response.EnsureSuccessStatusCode(); - var talks = await response.Content.ReadFromJsonAsync>(CancellationToken.None); + var talks = await response.Content.ReadFromJsonAsync>(CancellationToken.None); var talk = Assert.Single(talks!); Assert.Equal(Guid.Parse("6c8d4d27-9cc7-4c41-9bf8-19e55758e7cc"), talk.Id); Assert.Equal("Finding TP for Your People's Bungholes", talk.Title);