using System.Text.Json; using Microsoft.Extensions.Options; public interface ISessionCatalog { IReadOnlyList ListSessions(); SessionPreset? GetById(string sessionId); } public sealed record SessionPreset( string Id, string Name, string Goal, int EstimatedMinutes, double DefaultSpeed, IReadOnlyList Scenarios); /// /// Loads sessions.json from the scenario directory (or its parent) and /// validates every referenced scenario id against /// at construction — a typo fails loudly at startup. /// public sealed class SessionCatalog : ISessionCatalog { private static readonly JsonSerializerOptions JsonOptions = new() { PropertyNameCaseInsensitive = true, }; private readonly IReadOnlyList _sessions; public SessionCatalog(IOptions options, IScenarioCatalog scenarios) { var directory = options.Value.ScenarioDirectory; var path = ResolveManifestPath(directory) ?? throw new InvalidOperationException( $"Simulation session manifest not found. Expected sessions.json in or beside " + $"ScenarioDirectory '{Path.GetFullPath(directory)}'."); _sessions = LoadAndValidate(path, scenarios); } public IReadOnlyList ListSessions() => _sessions; public SessionPreset? GetById(string sessionId) => _sessions.FirstOrDefault(s => string.Equals(s.Id, sessionId, StringComparison.OrdinalIgnoreCase)); internal static string? ResolveManifestPath(string scenarioDirectory) { var inDir = Path.Combine(scenarioDirectory, "sessions.json"); if (File.Exists(inDir)) return inDir; var parent = Directory.GetParent(Path.GetFullPath(scenarioDirectory))?.FullName; if (parent is not null) { var beside = Path.Combine(parent, "sessions.json"); if (File.Exists(beside)) return beside; } return null; } internal static IReadOnlyList LoadAndValidate( string path, IScenarioCatalog scenarios) { SessionsDocument doc; try { var json = File.ReadAllText(path); doc = JsonSerializer.Deserialize(json, JsonOptions) ?? throw new InvalidOperationException( $"Session manifest '{path}' deserialized to null."); } catch (Exception ex) when (ex is not InvalidOperationException) { throw new InvalidOperationException( $"Failed to load simulation session manifest '{path}': {ex.Message}", ex); } if (doc.Sessions is null || doc.Sessions.Count == 0) throw new InvalidOperationException( $"Session manifest '{path}' contains no sessions."); var presets = new List(doc.Sessions.Count); var missing = new List(); foreach (var entry in doc.Sessions) { if (string.IsNullOrWhiteSpace(entry.Id)) throw new InvalidOperationException( $"Session manifest '{path}' has a session with an empty id."); if (entry.Scenarios is null || entry.Scenarios.Count == 0) throw new InvalidOperationException( $"Session '{entry.Id}' in '{path}' lists no scenarios."); foreach (var scenarioId in entry.Scenarios) { if (scenarios.GetById(scenarioId) is null) missing.Add($"{entry.Id} → {scenarioId}"); } presets.Add(new SessionPreset( entry.Id, entry.Name ?? entry.Id, entry.Goal ?? string.Empty, entry.EstimatedMinutes, entry.DefaultSpeed <= 0 ? 60 : entry.DefaultSpeed, entry.Scenarios.AsReadOnly())); } if (missing.Count > 0) { throw new InvalidOperationException( "Session manifest references unknown scenario id(s): " + string.Join("; ", missing)); } return presets; } private sealed class SessionsDocument { public List? Sessions { get; set; } } private sealed class SessionEntry { public string Id { get; set; } = null!; public string? Name { get; set; } public string? Goal { get; set; } public int EstimatedMinutes { get; set; } public double DefaultSpeed { get; set; } public List? Scenarios { get; set; } } }