using System;
namespace Unity.Pipeline.Editor.Commands.PackageManager
{
/// Where an added package comes from, derived from its identifier string (CLI-203).
public enum PackageSourceKind
{
/// Could not be classified (e.g. empty input).
Unknown,
/// A registry package name, optionally pinned: com.unity.foo or com.unity.foo@1.2.3.
Registry,
/// A git URL: https://….git, git+…, ssh://…, git@host:…, optionally #revision.
Git,
/// A local package reference: file:../RelativePath or file:/abs/path.
Local
}
/// A parsed UPM package_add identifier — the normalized string handed to
/// Client.Add plus the pieces extracted for plan/audit text.
public sealed class ParsedPackageId
{
/// How the package will be resolved by UPM.
public PackageSourceKind Kind { get; set; }
/// The identifier passed verbatim to UnityEditor.PackageManager.Client.Add.
public string Identifier { get; set; }
/// Registry package name (Registry only); null otherwise.
public string Name { get; set; }
/// Registry version or git revision when given; null otherwise.
public string Version { get; set; }
/// Human-readable summary for plan/audit text.
public string Description { get; set; }
}
///
/// Pure, editor-independent classification of a package_add identifier into a registry name,
/// git URL, or local path (CLI-203). Kept free of any Unity API so the add-path's input handling is
/// unit-testable without a live editor; the command layer feeds the result to
/// UnityEditor.PackageManager.Client.Add.
///
public static class PackageIdentifier
{
/// Classify an identifier without parsing out its parts.
public static PackageSourceKind Classify(string identifier)
{
if (string.IsNullOrWhiteSpace(identifier))
return PackageSourceKind.Unknown;
var id = identifier.Trim();
if (StartsWith(id, "file:"))
return PackageSourceKind.Local;
// Explicit git forms and any URL scheme (UPM treats http(s) identifiers as git URLs).
if (StartsWith(id, "git+") || StartsWith(id, "ssh://") || id.StartsWith("git@", StringComparison.Ordinal))
return PackageSourceKind.Git;
if (StartsWith(id, "http://") || StartsWith(id, "https://") || id.Contains("://"))
return PackageSourceKind.Git;
// A bare "owner/repo.git" (no scheme) is still a git reference. Strip an optional
// "#revision" before testing the suffix.
var core = id;
var hash = core.IndexOf('#');
if (hash >= 0)
core = core.Substring(0, hash);
if (core.EndsWith(".git", StringComparison.OrdinalIgnoreCase))
return PackageSourceKind.Git;
return PackageSourceKind.Registry;
}
///
/// Classify and split . Returns false with
/// set for empty/unclassifiable input.
///
public static bool TryParse(string identifier, out ParsedPackageId parsed, out string error)
{
parsed = null;
error = null;
if (string.IsNullOrWhiteSpace(identifier))
{
error = "Package identifier is empty. Provide a name (com.unity.foo[@version]), a git URL, or a file: path.";
return false;
}
var id = identifier.Trim();
switch (Classify(id))
{
case PackageSourceKind.Local:
parsed = new ParsedPackageId
{
Kind = PackageSourceKind.Local,
Identifier = id,
Description = $"local package '{id}'"
};
return true;
case PackageSourceKind.Git:
{
string revision = null;
var hash = id.IndexOf('#');
if (hash >= 0 && hash < id.Length - 1)
revision = id.Substring(hash + 1);
parsed = new ParsedPackageId
{
Kind = PackageSourceKind.Git,
Identifier = id,
Version = revision,
Description = revision != null ? $"git package '{id}' @ {revision}" : $"git package '{id}'"
};
return true;
}
case PackageSourceKind.Registry:
{
var name = id;
string version = null;
// Split on the version separator '@'. Use the last '@' so npm-scoped names
// ("@scope/pkg@1.0.0") still split on the version, not the leading scope marker.
var at = id.LastIndexOf('@');
if (at > 0)
{
name = id.Substring(0, at);
version = id.Substring(at + 1);
}
if (string.IsNullOrWhiteSpace(name))
{
error = $"Package name is empty in '{identifier}'.";
return false;
}
parsed = new ParsedPackageId
{
Kind = PackageSourceKind.Registry,
Identifier = id,
Name = name,
Version = version,
Description = version != null ? $"'{name}' @ {version}" : $"'{name}' (latest compatible)"
};
return true;
}
default:
error = $"Could not classify package identifier '{identifier}'.";
return false;
}
}
private static bool StartsWith(string value, string prefix) =>
value.StartsWith(prefix, StringComparison.OrdinalIgnoreCase);
}
}