224 lines
8.1 KiB
C#
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;
|
|
}
|
|
}
|