using System.Text.Json; using System.Text.Json.Nodes; using Sdt.Core; namespace Sdt.Config; public sealed record LoadedProjectConfig( DevToolConfig Config, string ProjectRoot, IReadOnlyList Warnings); public sealed record LegacyMigrationApplyResult( bool Success, string Message, string? BackupPath = null, string? ConfigPath = null); public static class ConfigLoader { public const string WorkspaceDefaultsFileName = "sdt-defaults.json"; private static readonly JsonSerializerOptions JsonOptions = new() { PropertyNameCaseInsensitive = true, PropertyNamingPolicy = JsonNamingPolicy.CamelCase, AllowTrailingCommas = true, ReadCommentHandling = JsonCommentHandling.Skip, }; /// /// Walks up from (or CWD) until it finds devtool.json. /// Returns null if not found. /// public static string? FindConfigPath(string? startDir = null) { var dir = new DirectoryInfo(startDir ?? Directory.GetCurrentDirectory()); while (dir is not null) { var candidate = Path.Combine(dir.FullName, "devtool.json"); if (File.Exists(candidate)) return candidate; dir = dir.Parent!; } return null; } public static LoadedProjectConfig? FindAndLoad(string? startDir = null) { var configPath = FindConfigPath(startDir); if (configPath is null) return null; var projectRoot = Path.GetDirectoryName(configPath) ?? throw new InvalidOperationException($"Could not resolve project root from {configPath}"); try { var effectiveConfig = LoadEffectiveConfig(projectRoot, configPath, out var defaultsPath); var warnings = new List(); if (!string.IsNullOrWhiteSpace(defaultsPath)) warnings.Add($"Applied workspace defaults from {defaultsPath}."); var legacyMode = ResolveLegacyMode(); if (legacyMode == LegacyMode.Strict && effectiveConfig.Workflows.Count == 0 && effectiveConfig.Targets.Count > 0) { var previewPath = Path.Combine(projectRoot, "devtool.generated.workflows.json"); try { var previewConfig = WorkflowModelBuilder.BuildMigrationPreviewConfig(effectiveConfig, new RequirementResolver()); File.WriteAllText(previewPath, ConfigBootstrapper.ToJson(previewConfig)); } catch { // Keep strict failure even if preview generation fails. } throw new InvalidOperationException( $"Legacy targets-only config detected at {configPath}. Strict mode requires workflows. " + "Use migration preview file 'devtool.generated.workflows.json' and migrate devtool.json. " + "Temporary rollback: set SDT_LEGACY_MODE=compat."); } var normalized = WorkflowModelBuilder.Normalize(effectiveConfig, legacyMode, new RequirementResolver()); warnings.AddRange(normalized.Warnings); return new LoadedProjectConfig(effectiveConfig, projectRoot, warnings); } catch (Exception ex) { throw new InvalidOperationException( $"Failed to parse devtool.json at {configPath}: {ex.Message}", ex); } } public static LegacyMigrationApplyResult ApplyLegacyTargetMigration( string configPath, bool createBackup = true) { try { if (!File.Exists(configPath)) return new LegacyMigrationApplyResult(false, $"Config file not found: {configPath}"); var json = File.ReadAllText(configPath); var config = JsonSerializer.Deserialize(json, JsonOptions) ?? throw new InvalidOperationException("devtool.json deserialized to null."); if (config.Targets.Count == 0) return new LegacyMigrationApplyResult(false, "No legacy targets found to migrate.", ConfigPath: configPath); var migrated = WorkflowModelBuilder.BuildMigrationPreviewConfig(config, new RequirementResolver()); var backupPath = (string?)null; if (createBackup) { backupPath = configPath + $".bak-{DateTimeOffset.Now:yyyyMMdd-HHmmss}"; File.Copy(configPath, backupPath, overwrite: false); } File.WriteAllText(configPath, ConfigBootstrapper.ToJson(migrated)); return new LegacyMigrationApplyResult( true, "Legacy targets migrated to workflows.", BackupPath: backupPath, ConfigPath: configPath); } catch (Exception ex) { return new LegacyMigrationApplyResult(false, ex.Message, ConfigPath: configPath); } } private static LegacyMode ResolveLegacyMode() { var raw = Environment.GetEnvironmentVariable("SDT_LEGACY_MODE"); return string.Equals(raw, "compat", StringComparison.OrdinalIgnoreCase) ? LegacyMode.Compat : LegacyMode.Strict; } private static DevToolConfig LoadEffectiveConfig( string projectRoot, string projectConfigPath, out string? defaultsPath) { defaultsPath = FindWorkspaceDefaultsPath(projectRoot); var projectObj = LoadJsonObject(projectConfigPath, "project config"); if (string.IsNullOrWhiteSpace(defaultsPath)) return DeserializeConfig(projectObj, projectConfigPath); var defaultsObj = LoadJsonObject(defaultsPath!, "workspace defaults"); var merged = MergeObjects(defaultsObj, projectObj); return DeserializeConfig(merged, projectConfigPath); } private static string? FindWorkspaceDefaultsPath(string startDir) { var workspaceBoundary = FindWorkspaceBoundary(startDir); var dir = new DirectoryInfo(startDir); while (dir is not null) { var candidate = Path.Combine(dir.FullName, WorkspaceDefaultsFileName); if (File.Exists(candidate)) return candidate; if (workspaceBoundary is not null && string.Equals(dir.FullName, workspaceBoundary, StringComparison.OrdinalIgnoreCase)) { break; } if (workspaceBoundary is null) break; dir = dir.Parent; } return null; } private static string? FindWorkspaceBoundary(string startDir) { var dir = new DirectoryInfo(startDir); while (dir is not null) { var workspacePath = Path.Combine(dir.FullName, WorkspaceLoader.FileName); if (File.Exists(workspacePath)) return dir.FullName; dir = dir.Parent; } return null; } private static JsonObject LoadJsonObject(string path, string label) { var json = File.ReadAllText(path); var node = JsonNode.Parse(json) ?? throw new InvalidOperationException($"{label} at {path} deserialized to null."); if (node is not JsonObject obj) throw new InvalidOperationException($"{label} at {path} must be a JSON object."); return obj; } private static DevToolConfig DeserializeConfig(JsonObject obj, string sourcePath) { return obj.Deserialize(JsonOptions) ?? throw new InvalidOperationException($"devtool.json at {sourcePath} deserialized to null."); } private static JsonObject MergeObjects(JsonObject baseObj, JsonObject overlayObj) { var result = (JsonObject)baseObj.DeepClone(); foreach (var kv in overlayObj) { if (kv.Value is JsonObject overlayChild && result[kv.Key] is JsonObject baseChild) { result[kv.Key] = MergeObjects(baseChild, overlayChild); continue; } result[kv.Key] = kv.Value?.DeepClone(); } return result; } }