using System.Text.RegularExpressions; using System.Text.Json; using System.Text.Json.Nodes; namespace Launcher.Next.Core; public sealed record LauncherPathVariables(IReadOnlyDictionary Values) { public string? this[string name] => Values.TryGetValue(name, out var value) ? value : null; } public sealed record ResolvedLauncherPaths(IReadOnlyDictionary Values) { public string Require(string name) => Values.TryGetValue(name, out var value) && !string.IsNullOrWhiteSpace(value) ? value : throw new InvalidOperationException($"PATH_VARIABLE_REQUIRED:{name}"); public string? Get(string name) => Values.TryGetValue(name, out var value) ? value : null; public string LocalLauncherRoot => Require(nameof(LocalLauncherRoot)); public string AcadExecutable => Require(nameof(AcadExecutable)); public string? AutomationScriptPath => Get(nameof(AutomationScriptPath)); public string? FixtureOrthoDwg => Get(nameof(FixtureOrthoDwg)); } public interface IPathResolver { ResolvedLauncherPaths Resolve(LauncherPathVariables variables, IEnumerable? required = null); string ResolvePath(string value, LauncherPathVariables variables, string? constrainedRoot = null); } public sealed class PathResolver(IReadOnlyDictionary? environment = null) : IPathResolver { private static readonly Regex Token = new(@"\$\{(?[A-Za-z][A-Za-z0-9_]*)\}", RegexOptions.Compiled); private readonly IReadOnlyDictionary environment = environment ?? Environment.GetEnvironmentVariables() .Cast().ToDictionary(x => (string)x.Key, x => x.Value?.ToString(), StringComparer.OrdinalIgnoreCase); public ResolvedLauncherPaths Resolve(LauncherPathVariables variables, IEnumerable? required = null) { var resolved = new Dictionary(StringComparer.OrdinalIgnoreCase); foreach (var name in variables.Values.Keys) resolved[name] = ResolveVariable(name, variables, resolved, []); foreach (var name in resolved.Keys.ToArray()) if (resolved[name] is { } value && value.StartsWith("A:\\", StringComparison.OrdinalIgnoreCase)) { var corporate = ResolveVariable(LauncherPathVariableNames.CorporateDfsRoot, variables, resolved, []) ?? throw new InvalidOperationException("PATH_VARIABLE_EMPTY:CorporateDfsRoot"); resolved[name] = Path.Combine(corporate, value[3..]); } foreach (var name in required ?? []) if (!resolved.TryGetValue(name, out var value) || string.IsNullOrWhiteSpace(value)) throw new InvalidOperationException($"PATH_VARIABLE_REQUIRED:{name}"); return new(resolved); } public string ResolvePath(string value, LauncherPathVariables variables, string? constrainedRoot = null) { var resolved = Expand(value, variables, new Dictionary(StringComparer.OrdinalIgnoreCase), []); if (string.IsNullOrWhiteSpace(resolved) || !Path.IsPathFullyQualified(resolved)) throw new InvalidOperationException("PATH_ABSOLUTE_REQUIRED"); if (resolved.StartsWith("A:\\", StringComparison.OrdinalIgnoreCase)) { var corporate = Resolve(variables, [LauncherPathVariableNames.CorporateDfsRoot]).Require(LauncherPathVariableNames.CorporateDfsRoot); resolved = Path.Combine(corporate, resolved[3..]); } var full = Path.GetFullPath(resolved); if (full.StartsWith("A:\\", StringComparison.OrdinalIgnoreCase)) throw new InvalidOperationException("LEGACY_DRIVE_A_UNRESOLVED"); if (!string.IsNullOrWhiteSpace(constrainedRoot)) { var root = Path.GetFullPath(ResolvePath(constrainedRoot, variables)); if (!full.Equals(root, StringComparison.OrdinalIgnoreCase) && !full.StartsWith(root.TrimEnd(Path.DirectorySeparatorChar) + Path.DirectorySeparatorChar, StringComparison.OrdinalIgnoreCase)) throw new InvalidOperationException("PATH_OUTSIDE_CONSTRAINED_ROOT"); } return full; } private string? ResolveVariable(string name, LauncherPathVariables variables, Dictionary resolved, HashSet stack) { if (resolved.TryGetValue(name, out var cached)) return cached; if (!stack.Add(name)) throw new InvalidOperationException($"PATH_VARIABLE_CYCLE:{name}"); try { var source = variables[name] ?? (environment.TryGetValue(name, out var fromEnvironment) ? fromEnvironment : null); if (source is null) throw new InvalidOperationException($"PATH_VARIABLE_UNKNOWN:{name}"); var expanded = Token.Replace(source, match => ResolveVariable(match.Groups["name"].Value, variables, resolved, stack) ?? throw new InvalidOperationException($"PATH_VARIABLE_EMPTY:{match.Groups["name"].Value}")); expanded = expanded.Replace('/', Path.DirectorySeparatorChar); resolved[name] = expanded; return expanded; } finally { stack.Remove(name); } } private string Expand(string value, LauncherPathVariables variables, Dictionary resolved, HashSet stack) => Token.Replace(value, match => ResolveVariable(match.Groups["name"].Value, variables, resolved, stack) ?? throw new InvalidOperationException($"PATH_VARIABLE_EMPTY:{match.Groups["name"].Value}")); } public static class LauncherPathVariableNames { public const string CorporateDfsRoot = "CorporateDfsRoot"; public const string CorporateLauncherRoot = "CorporateLauncherRoot"; public const string CorporateCatalogRoot = "CorporateCatalogRoot"; public const string CorporatePackagesRoot = "CorporatePackagesRoot"; public const string CorporateProfilesRoot = "CorporateProfilesRoot"; public const string CorporatePalettesRoot = "CorporatePalettesRoot"; public const string CorporateFontsRoot = "CorporateFontsRoot"; public const string CorporateTemplatesRoot = "CorporateTemplatesRoot"; public const string CorporateScriptsRoot = "CorporateScriptsRoot"; public const string ProjectName = "ProjectName"; public const string DisciplineName = "DisciplineName"; public const string ProjectRoot = "ProjectRoot"; public const string DisciplineRoot = "DisciplineRoot"; public const string PlantProjectFile = "PlantProjectFile"; public const string ProjectRosterPath = "ProjectRosterPath"; public const string ProjectApplicationsRoot = "ProjectApplicationsRoot"; public const string ProjectPaletteOverlayRoot = "ProjectPaletteOverlayRoot"; public const string LocalLauncherRoot = "LocalLauncherRoot"; public const string LocalPackageCache = "LocalPackageCache"; public const string LocalActiveRoot = "LocalActiveRoot"; public const string LocalSessionRoot = "LocalSessionRoot"; public const string LocalEvidenceRoot = "LocalEvidenceRoot"; public const string LocalLogsRoot = "LocalLogsRoot"; public const string LocalRollbackRoot = "LocalRollbackRoot"; public const string AcadExecutable = "AcadExecutable"; public const string PRecEntryAssembly = "PRecEntryAssembly"; public const string FixtureOrthoDwg = "FixtureOrthoDwg"; public const string AutomationScriptPath = "AutomationScriptPath"; public const string AutomationConfigPath = "AutomationConfigPath"; public const string AutomationEvidencePath = "AutomationEvidencePath"; public const string AutomationPermitPath = "AutomationPermitPath"; public const string AutomationTranscriptPath = "AutomationTranscriptPath"; } public static class LauncherConfigurationLayers { private static readonly HashSet Protected = [LauncherPathVariableNames.CorporateDfsRoot, LauncherPathVariableNames.AcadExecutable, LauncherPathVariableNames.LocalLauncherRoot]; public static PilotConfigurationLoadResult Load(string? corporateDefaultsJson, string? machineJson, string? sessionJson) { try { var merged = Parse(corporateDefaultsJson); Merge(merged, Parse(machineJson), false); Merge(merged, Parse(sessionJson), false); return PilotConfigurationParser.Parse(merged.ToJsonString()); } catch (JsonException) { return new(PilotLocalConfiguration.SafeDefault, ["PILOT_CONFIGURATION_INVALID"]); } } public static PilotConfigurationLoadResult LoadFromFiles(string corporateDefaultsPath, string machinePath, string sessionPath) { var defaults = File.Exists(corporateDefaultsPath) ? File.ReadAllText(corporateDefaultsPath) : null; var machine = File.Exists(machinePath) ? File.ReadAllText(machinePath) : null; var session = File.Exists(sessionPath) ? File.ReadAllText(sessionPath) : null; return Load(defaults, machine, session); } private static JsonObject Parse(string? json) => string.IsNullOrWhiteSpace(json) ? [] : JsonNode.Parse(json)?.AsObject() ?? []; private static void Merge(JsonObject target, JsonObject overlay, bool roster) { foreach (var entry in overlay) { if (entry.Key.Equals("PathVariables", StringComparison.OrdinalIgnoreCase) && entry.Value is JsonObject paths) { var targetPaths = target["PathVariables"] as JsonObject ?? new JsonObject(); target["PathVariables"] = targetPaths; foreach (var path in paths) if (!roster || !Protected.Contains(path.Key)) targetPaths[path.Key] = path.Value?.DeepClone(); } else target[entry.Key] = entry.Value?.DeepClone(); } } }