journal/Journal.DevTool/Config/ConfigLoader.cs

224 lines
8.1 KiB
C#

using System.Text.Json;
using System.Text.Json.Nodes;
using Sdt.Core;
namespace Sdt.Config;
public sealed record LoadedProjectConfig(
DevToolConfig Config,
string ProjectRoot,
IReadOnlyList<string> 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,
};
/// <summary>
/// Walks up from <paramref name="startDir"/> (or CWD) until it finds devtool.json.
/// Returns null if not found.
/// </summary>
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<string>();
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<DevToolConfig>(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<DevToolConfig>(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;
}
}