rustwright-core 0.1.1

Rust CDP core for a Python Playwright-compatible automation API
using System.Text.Json;

namespace Rustwright.Runner;

internal sealed record Manifest(IReadOnlyList<ManifestCase> Cases);

internal sealed record ManifestCase(string Id, string? Html, IReadOnlyList<JsonElement> Steps);

internal static class ManifestParser
{
    private static readonly HashSet<string> RootProperties = ["version", "cases"];
    private static readonly HashSet<string> CaseProperties = ["id", "description", "html", "url", "steps"];

    internal static Manifest Parse(string path)
    {
        JsonDocument document;
        try
        {
            document = JsonDocument.Parse(File.ReadAllText(path), new JsonDocumentOptions
            {
                AllowTrailingCommas = false,
                CommentHandling = JsonCommentHandling.Disallow,
                MaxDepth = 128,
            });
        }
        catch (Exception error) when (error is IOException or JsonException)
        {
            throw new ManifestException($"cannot read manifest: {error.Message}");
        }

        using (document)
        {
            var root = document.RootElement;
            RequireObject(root, "manifest");
            ValidatePropertySet(root, RootProperties, ["version", "cases"], "manifest");

            var version = root.GetProperty("version");
            if (version.ValueKind != JsonValueKind.Number || !version.TryGetInt32(out var versionNumber) || versionNumber != 1)
            {
                throw new ManifestException("manifest.version must be 1");
            }

            var casesElement = root.GetProperty("cases");
            if (casesElement.ValueKind != JsonValueKind.Array || casesElement.GetArrayLength() == 0)
            {
                throw new ManifestException("manifest.cases must be a nonempty array");
            }

            var ids = new HashSet<string>(StringComparer.Ordinal);
            var cases = new List<ManifestCase>(casesElement.GetArrayLength());
            var caseIndex = 0;
            foreach (var caseElement in casesElement.EnumerateArray())
            {
                cases.Add(ParseCase(caseElement, caseIndex, ids));
                caseIndex++;
            }

            return new Manifest(cases);
        }
    }

    private static ManifestCase ParseCase(JsonElement element, int caseIndex, HashSet<string> ids)
    {
        var context = $"cases[{caseIndex}]";
        RequireObject(element, context);
        ValidatePropertySet(element, CaseProperties, ["id", "steps"], context);

        var id = RequiredNonemptyString(element, "id", context);
        if (!ids.Add(id))
        {
            throw new ManifestException($"duplicate case id '{id}'");
        }

        OptionalString(element, "description", context);
        OptionalString(element, "html", context);
        OptionalString(element, "url", context);
        var html = element.TryGetProperty("html", out var htmlElement) ? htmlElement.GetString() : null;

        var stepsElement = element.GetProperty("steps");
        if (stepsElement.ValueKind != JsonValueKind.Array || stepsElement.GetArrayLength() == 0)
        {
            throw new ManifestException($"{context}.steps must be a nonempty array");
        }

        var captures = new HashSet<string>(StringComparer.Ordinal);
        var steps = new List<JsonElement>(stepsElement.GetArrayLength());
        var stepIndex = 0;
        foreach (var step in stepsElement.EnumerateArray())
        {
            ValidateStep(step, html is not null, context, stepIndex, captures);
            steps.Add(step.Clone());
            stepIndex++;
        }

        return new ManifestCase(id, html, steps);
    }

    private static void ValidateStep(
        JsonElement step,
        bool hasCaseHtml,
        string caseContext,
        int stepIndex,
        HashSet<string> captures)
    {
        var context = $"{caseContext}.steps[{stepIndex}]";
        RequireObject(step, context);
        if (!step.TryGetProperty("op", out var operationElement) || operationElement.ValueKind != JsonValueKind.String)
        {
            throw new ManifestException($"{context}.op must be a string");
        }

        var operation = operationElement.GetString()!;
        switch (operation)
        {
            case "goto":
                ValidateGoto(step, hasCaseHtml, context);
                break;
            case "click":
                ValidatePropertySet(step, ["op", "selector"], ["op", "selector"], context);
                _ = RequiredNonemptyString(step, "selector", context);
                break;
            case "fill":
                ValidatePropertySet(step, ["op", "selector", "value"], ["op", "selector", "value"], context);
                _ = RequiredNonemptyString(step, "selector", context);
                RequiredString(step, "value", context);
                break;
            case "title":
                ValidateCapture(step, ["op", "capture"], context, captures);
                break;
            case "textContent":
                ValidateCapture(step, ["op", "selector", "capture"], context, captures);
                _ = RequiredNonemptyString(step, "selector", context);
                break;
            case "evaluate":
                ValidatePropertySet(step, ["op", "expression", "arg", "capture"], ["op", "expression", "capture"], context);
                _ = RequiredNonemptyString(step, "expression", context);
                AddCapture(RequiredNonemptyString(step, "capture", context), captures, context);
                break;
            case "screenshot":
                ValidateCapture(step, ["op", "capture"], context, captures);
                break;
            case "assertTitle":
                ValidateAssertion(step, ["op", "equals", "contains"], context, selectorRequired: false);
                break;
            case "assertText":
                ValidateAssertion(step, ["op", "selector", "equals", "contains"], context, selectorRequired: true);
                break;
            case "assertEval":
                ValidatePropertySet(step, ["op", "expression", "equals"], ["op", "expression", "equals"], context);
                _ = RequiredNonemptyString(step, "expression", context);
                break;
            default:
                throw new ManifestException($"{context}: unknown op '{operation}'");
        }
    }

    private static void ValidateGoto(JsonElement step, bool hasCaseHtml, string context)
    {
        ValidatePropertySet(step, ["op", "url", "useCaseHtml", "waitUntil"], ["op"], context);
        var hasUrl = step.TryGetProperty("url", out var url);
        var usesCaseHtml = step.TryGetProperty("useCaseHtml", out var useCaseHtml);
        if (hasUrl == usesCaseHtml)
        {
            throw new ManifestException($"{context} must have exactly one of url or useCaseHtml");
        }

        if (hasUrl)
        {
            if (url.ValueKind != JsonValueKind.String || string.IsNullOrEmpty(url.GetString()))
            {
                throw new ManifestException($"{context}.url must be a nonempty string");
            }
        }
        else if (useCaseHtml.ValueKind != JsonValueKind.True)
        {
            throw new ManifestException($"{context}.useCaseHtml must be true");
        }
        else if (!hasCaseHtml)
        {
            throw new ManifestException($"{context} uses case HTML but the case has no html property");
        }

        if (step.TryGetProperty("waitUntil", out var waitUntil))
        {
            if (waitUntil.ValueKind != JsonValueKind.String ||
                waitUntil.GetString() is not ("load" or "domcontentloaded" or "networkidle" or "commit"))
            {
                throw new ManifestException($"{context}.waitUntil is invalid");
            }
        }
    }

    private static void ValidateCapture(
        JsonElement step,
        HashSet<string> allowed,
        string context,
        HashSet<string> captures)
    {
        ValidatePropertySet(step, allowed, allowed, context);
        AddCapture(RequiredNonemptyString(step, "capture", context), captures, context);
    }

    private static void ValidateAssertion(
        JsonElement step,
        HashSet<string> allowed,
        string context,
        bool selectorRequired)
    {
        var required = selectorRequired ? new HashSet<string>(["op", "selector"]) : new HashSet<string>(["op"]);
        ValidatePropertySet(step, allowed, required, context);
        if (selectorRequired)
        {
            _ = RequiredNonemptyString(step, "selector", context);
        }

        var hasEquals = step.TryGetProperty("equals", out var equals);
        var hasContains = step.TryGetProperty("contains", out var contains);
        if (hasEquals == hasContains)
        {
            throw new ManifestException($"{context} must have exactly one of equals or contains");
        }

        var predicate = hasEquals ? equals : contains;
        if (predicate.ValueKind != JsonValueKind.String)
        {
            throw new ManifestException($"{context} assertion predicate must be a string");
        }
    }

    private static void ValidatePropertySet(
        JsonElement element,
        HashSet<string> allowed,
        HashSet<string> required,
        string context)
    {
        var seen = new HashSet<string>(StringComparer.Ordinal);
        foreach (var property in element.EnumerateObject())
        {
            if (!seen.Add(property.Name))
            {
                throw new ManifestException($"{context} has duplicate property '{property.Name}'");
            }

            if (!allowed.Contains(property.Name))
            {
                throw new ManifestException($"{context} has unknown property '{property.Name}'");
            }
        }

        foreach (var property in required)
        {
            if (!seen.Contains(property))
            {
                throw new ManifestException($"{context} is missing required property '{property}'");
            }
        }
    }

    private static string RequiredNonemptyString(JsonElement element, string property, string context)
    {
        var value = RequiredString(element, property, context);
        if (value.Length == 0)
        {
            throw new ManifestException($"{context}.{property} must be nonempty");
        }

        return value;
    }

    private static string RequiredString(JsonElement element, string property, string context)
    {
        var value = element.GetProperty(property);
        if (value.ValueKind != JsonValueKind.String)
        {
            throw new ManifestException($"{context}.{property} must be a string");
        }

        return value.GetString()!;
    }

    private static void OptionalString(JsonElement element, string property, string context)
    {
        if (element.TryGetProperty(property, out var value) && value.ValueKind != JsonValueKind.String)
        {
            throw new ManifestException($"{context}.{property} must be a string");
        }
    }

    private static void AddCapture(string capture, HashSet<string> captures, string context)
    {
        if (!captures.Add(capture))
        {
            throw new ManifestException($"{context}: duplicate capture '{capture}'");
        }
    }

    private static void RequireObject(JsonElement element, string context)
    {
        if (element.ValueKind != JsonValueKind.Object)
        {
            throw new ManifestException($"{context} must be an object");
        }
    }
}

internal sealed class ManifestException : Exception
{
    internal ManifestException(string message)
        : base(message)
    {
    }
}