/*
+------------------------------------------------------------------+
| Author: Ivan Murzak (https://github.com/IvanMurzak) |
| Repository: GitHub (https://github.com/IvanMurzak/Unity-MCP) |
| Copyright (c) 2025 Ivan Murzak |
| Licensed under the Apache License, Version 2.0. |
| See the LICENSE file in the project root for more information. |
+------------------------------------------------------------------+
*/
#nullable enable
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Text;
using System.Text.RegularExpressions;
using UnityEngine;
namespace com.IvanMurzak.Unity.MCP.Editor.DependencyResolver
{
///
/// On-disk manifest for the flat-layout NuGet install path. Replaces the
/// directory-name-as-source-of-truth model that the per-package
/// {Id}.{Version}/ layout used pre-#733.
///
/// File location: {installPath}/.nuget-installed.json.
/// Schema:
///
/// {
/// "packages": {
/// "Microsoft.AspNetCore.Http.Connections.Client": {
/// "version": "8.0.15",
/// "dlls": ["Microsoft.AspNetCore.Http.Connections.Client.8.0.15.dll"]
/// },
/// "Microsoft.Bcl.Memory": {
/// "version": "10.0.3",
/// "dlls": [
/// "System.Memory.10.0.3.dll",
/// "System.Buffers.10.0.3.dll",
/// "System.Runtime.CompilerServices.Unsafe.10.0.3.dll"
/// ]
/// }
/// }
/// }
///
///
/// The manifest is the primary source of truth for "which DLL belongs to
/// which package at which version". Filename parsing
/// () is the disaster-recovery
/// fallback — even if the manifest is deleted, the on-disk filenames
/// carry enough metadata for to
/// reconstruct one.
///
static class NuGetInstallManifest
{
const string Tag = NuGetConfig.LogTag;
public const string FileName = ".nuget-installed.json";
///
/// Returns the absolute path to the manifest file under the supplied
/// install path.
///
public static string GetPath(string installPath) =>
Path.Combine(installPath, FileName);
///
/// Loads the manifest from disk. Returns an empty manifest when the file
/// is missing or unparseable; the caller is responsible for treating
/// that as "needs full restore".
///
public static InstallManifest Load(string installPath)
{
var path = GetPath(installPath);
if (!File.Exists(path))
return new InstallManifest();
try
{
var json = File.ReadAllText(path);
return ManifestJsonParser.Parse(json);
}
catch (Exception ex)
{
Debug.LogWarning($"{Tag} Manifest at '{path}' could not be parsed ({ex.Message}); treating as empty and rebuilding.");
return new InstallManifest();
}
}
///
/// Writes the manifest to disk atomically. Creates the install directory
/// if it does not exist. Writes to a sibling .tmp file first and
/// then moves it into place, so a crash or power loss mid-write cannot
/// leave a truncated/empty .nuget-installed.json behind.
///
public static void Save(string installPath, InstallManifest manifest)
{
if (!Directory.Exists(installPath))
Directory.CreateDirectory(installPath);
var path = GetPath(installPath);
var tempPath = path + ".tmp";
var json = ManifestJsonParser.Serialize(manifest);
File.WriteAllText(tempPath, json);
try
{
// Atomic rename: delete the destination first so File.Move (the
// .NET Standard 2.0-compatible overload that Unity's bundled
// Mono ships) does not throw IOException("destination exists").
// The delete-then-move sequence is not strictly atomic on
// Windows, but it removes the truncated-file failure mode the
// bug report calls out (a crash mid-WriteAllText would leave an
// empty/partial .nuget-installed.json; the staging-file
// approach guarantees the destination is either the previous
// valid file or the freshly serialized one — never partial).
if (File.Exists(path))
File.Delete(path);
File.Move(tempPath, path);
}
catch
{
// Best-effort cleanup of the staging file if the rename fails.
try { if (File.Exists(tempPath)) File.Delete(tempPath); }
catch { /* ignore cleanup error — surface the original failure */ }
throw;
}
}
///
/// Rebuilds an in-memory manifest by scanning the install directory
/// for the legacy versioned DLL filename shape
/// {stem}.{version}.dll. Unversioned canonical filenames are
/// skipped (we can't recover their version from disk; if the manifest
/// is missing, the next Restore() re-extracts every package
/// from .nupkg cache and rebuilds the manifest authoritatively).
///
/// Used when .nuget-installed.json is missing, corrupt, or
/// out-of-sync with disk (the disaster-recovery path). The caller is
/// responsible for persisting the rebuilt manifest via .
///
///
/// Filename-only rebuild cannot recover the package ID for multi-DLL
/// packages where each DLL stem differs from the package ID (e.g.
/// Microsoft.Bcl.Memory ships System.Memory.10.0.3.dll).
/// For such DLLs the rebuild treats the DLL stem itself as the package
/// ID; the next full restore reconciles the synthetic IDs against the
/// dependency closure (the closure is the authoritative source). This
/// is enough to satisfy the steady-state "manifest deleted, no
/// re-extraction needed" disaster-recovery acceptance criterion for
/// the pre-unversioned-filename layouts that still ship versioned
/// filenames on disk.
///
///
public static InstallManifest TryRebuildFromDisk(string installPath)
{
var manifest = new InstallManifest();
if (!Directory.Exists(installPath))
return manifest;
foreach (var dllPath in Directory.GetFiles(installPath, "*.dll", SearchOption.TopDirectoryOnly))
{
var fileName = Path.GetFileName(dllPath);
if (!TryParseInstalledDllName(fileName, out var stem, out var version) || stem == null || version == null)
continue;
if (!manifest.Packages.TryGetValue(stem, out var entry))
{
entry = new InstalledPackage(version);
manifest.Packages[stem] = entry;
}
else if (!string.Equals(entry.Version, version, StringComparison.OrdinalIgnoreCase))
{
// Multiple versions of the same DLL stem on disk shouldn't
// happen in a healthy install. If it does, latest-wins for
// the rebuild and the next Restore() pass will reconcile
// and clean up the loser.
entry = new InstalledPackage(version);
manifest.Packages[stem] = entry;
}
if (!entry.Dlls.Contains(fileName, StringComparer.OrdinalIgnoreCase))
entry.Dlls.Add(fileName);
}
return manifest;
}
///
/// Splits a versioned install filename of the form
/// {stem}.{version}.dll back into its stem and version parts.
/// Returns false for canonical unversioned names (e.g. System.Memory.dll)
/// or names whose version-tail does not parse as a System.Version —
/// useful as a "is this a leftover from the pre-unversioned-filename
/// layout?" predicate.
///
/// The version segment is matched as \d+(\.\d+){1,3} at the
/// tail before .dll. The match is greedy on the tail so that
/// System.Memory.10.0.3.dll splits as stem=System.Memory
/// + version=10.0.3, not stem=System.Memory.10.0 +
/// version=3.
///
public static bool TryParseInstalledDllName(string fileName, out string? stem, out string? version)
{
stem = null;
version = null;
if (string.IsNullOrEmpty(fileName))
return false;
if (!fileName.EndsWith(".dll", StringComparison.OrdinalIgnoreCase))
return false;
var noExt = fileName.Substring(0, fileName.Length - 4);
var match = VersionTailPattern.Match(noExt);
if (!match.Success)
return false;
var versionString = match.Groups[1].Value;
if (!System.Version.TryParse(versionString, out _))
return false;
var stemEnd = match.Index;
if (stemEnd <= 0)
return false;
stem = noExt.Substring(0, stemEnd);
version = versionString;
return true;
}
// Anchor a 1-4 segment numeric tail (each segment 1-9 digits — System.Version's
// ceiling) to the end of the stem. Used to detect / peel the version off
// filenames left over from the pre-unversioned-filename layout.
static readonly Regex VersionTailPattern = new(@"\.(\d+(?:\.\d+){1,3})$", RegexOptions.Compiled);
}
///
/// In-memory representation of 's on-disk schema.
///
sealed class InstallManifest
{
///
/// Map of NuGet package ID → installed entry. Comparison is
/// case-insensitive to match the rest of the resolver.
///
public Dictionary Packages { get; }
= new(StringComparer.OrdinalIgnoreCase);
public InstallManifest Clone()
{
var copy = new InstallManifest();
foreach (var kv in Packages)
copy.Packages[kv.Key] = new InstalledPackage(kv.Value.Version, kv.Value.Dlls);
return copy;
}
}
///
/// One entry in .
///
sealed class InstalledPackage
{
/// The package version this entry was installed at.
public string Version { get; set; }
///
/// DLL filenames (relative to the install path) the package owns at this
/// version. Each filename is of the form {stem}.{version}.dll.
///
public List Dlls { get; }
public InstalledPackage(string version)
{
Version = version;
Dlls = new List();
}
public InstalledPackage(string version, IEnumerable dlls)
{
Version = version;
Dlls = new List(dlls);
}
}
///
/// Hand-rolled JSON read/write for . Avoids a
/// dependency on System.Text.Json / Newtonsoft.Json so the
/// DependencyResolver assembly stays the only one that compiles before any
/// NuGet package is on disk (the same invariant NuGetDependencyResolver
/// is built against).
///
static class ManifestJsonParser
{
public static string Serialize(InstallManifest manifest)
{
var sb = new StringBuilder();
sb.AppendLine("{");
sb.AppendLine(" \"packages\": {");
// Stable ordering — keeps the file diff-friendly across runs.
var ordered = manifest.Packages
.OrderBy(kv => kv.Key, StringComparer.OrdinalIgnoreCase)
.ToList();
for (var i = 0; i < ordered.Count; i++)
{
var (id, entry) = (ordered[i].Key, ordered[i].Value);
sb.Append(" \"").Append(JsonEscape(id)).Append("\": {");
sb.AppendLine();
sb.Append(" \"version\": \"").Append(JsonEscape(entry.Version)).Append("\",");
sb.AppendLine();
sb.Append(" \"dlls\": [");
if (entry.Dlls.Count == 0)
{
sb.Append("]");
}
else
{
sb.AppendLine();
for (var j = 0; j < entry.Dlls.Count; j++)
{
sb.Append(" \"").Append(JsonEscape(entry.Dlls[j])).Append("\"");
if (j < entry.Dlls.Count - 1)
sb.Append(",");
sb.AppendLine();
}
sb.Append(" ]");
}
sb.AppendLine();
sb.Append(" }");
if (i < ordered.Count - 1)
sb.Append(",");
sb.AppendLine();
}
sb.AppendLine(" }");
sb.Append("}");
return sb.ToString();
}
public static InstallManifest Parse(string json)
{
var manifest = new InstallManifest();
var parser = new MicroJsonParser(json);
var root = parser.ParseValue() as Dictionary;
if (root == null)
return manifest;
if (!root.TryGetValue("packages", out var packagesObj) || packagesObj is not Dictionary packages)
return manifest;
foreach (var (id, valueObj) in packages)
{
if (valueObj is not Dictionary entryObj)
continue;
var version = entryObj.TryGetValue("version", out var versionObj) ? versionObj as string : null;
if (string.IsNullOrEmpty(version))
continue;
var entry = new InstalledPackage(version!);
if (entryObj.TryGetValue("dlls", out var dllsObj) && dllsObj is List