From df3cb6fb552c8c8102ad24bf8c23ed78a82baaa1 Mon Sep 17 00:00:00 2001 From: Copilot Date: Sat, 18 Jul 2026 13:09:59 -0500 Subject: [PATCH] feat(cli): add validate --output json for agent-native reports Emit structured validation pipeline results on stdout with console log suppression so agents can parse exit status, stage summaries, and dry-run issues without desktop ValidatePage UX. --- docs/knowledgebase/agent-action-parity.md | 5 +- docs/knowledgebase/agent-native-audit.md | 8 +- docs/knowledgebase/core-cli-reference.md | 7 +- src/ModSync.Core/CLI/ModBuildConverter.cs | 181 +++++++++++++++--- src/ModSync.Core/Logger.cs | 14 +- src/ModSync.Core/NLog.config | 23 ++- .../ValidationPipelineJsonFormatter.cs | 181 ++++++++++++++++++ src/ModSync.Tests/ValidateCliJsonTests.cs | 175 +++++++++++++++++ .../ValidationPipelineJsonFormatterTests.cs | 67 +++++++ 9 files changed, 623 insertions(+), 38 deletions(-) create mode 100644 src/ModSync.Core/Services/Validation/ValidationPipelineJsonFormatter.cs create mode 100644 src/ModSync.Tests/ValidateCliJsonTests.cs create mode 100644 src/ModSync.Tests/ValidationPipelineJsonFormatterTests.cs diff --git a/docs/knowledgebase/agent-action-parity.md b/docs/knowledgebase/agent-action-parity.md index 046277b7..9b69b36e 100644 --- a/docs/knowledgebase/agent-action-parity.md +++ b/docs/knowledgebase/agent-action-parity.md @@ -16,7 +16,7 @@ Wizard order from `src/ModSync.GUI/Dialogs/InstallWizardDialog.axaml.cs` and `AG | 6 | `AspyrNoticePage` | Acknowledge (K2) | No CLI equivalent | UI | | 7 | `ModSelectionPage` | Select mods, filters | `install` without `--select` = select all; `install --select category:X` / `tier:X` | Full (install); Partial (subset only with `--select`) | | 8 | `DownloadsExplainPage` | Continue (downloads may run) | `install -d` or `convert -d` (+ FOMOD: TTY / `--fomod-choices` / `--fomod-skip`) | Partial — live download status is `[UI]`; FOMOD configure is Full via CLI (see [fomod-support.md](fomod-support.md)) | -| 9 | `ValidatePage` | Run validation | `validate --full --dry-run --use-file-selection` (same Core `InstallationValidationPipeline` as GUI) | Full | +| 9 | `ValidatePage` | Run validation | `validate --full --dry-run --use-file-selection --output json` (same Core `InstallationValidationPipeline` as GUI) | Full | | 10 | `InstallStartPage` | Confirm install | `install -y` (runs `InstallationValidationPipeline` / `WizardFull` pre-check unless `--skip-validation`) | Full | | 10b | Managed deploy | Opt-in hardlink deploy via active profile | `install --managed` / `--no-managed` / `--profile` (#177); settings toggle still GUI | Full (CLI overrides); Partial (profile CRUD `[UI]`) — [managed-deployment.md](managed-deployment.md) | | 11 | `InstallingPage` | Watch progress | `install` (console progress) | Full — see [install-lifecycle.md](install-lifecycle.md) | @@ -65,6 +65,7 @@ Wizard order from `src/ModSync.GUI/Dialogs/InstallWizardDialog.axaml.cs` and `AG | Wizard UI | `WizardFlowHeadlessTests` | Page flow without full desktop | | GUI UX smoke | `GuiSmokeHeadlessTests` | Paste-import button + `LoadInstructionTextAsync` markdown (no clipboard), Welcome→ValidatePage key controls, compact ScrollViewer layout, ValidatePage log splitter | | Guide ingest | `GuideIngestionTests` | `--stdin` / `--parse-directions` draft + sandboxed paths | +| Validate JSON | `ValidateCliJsonTests`, `ValidationPipelineJsonFormatterTests` | `validate --output json` structured stdout | | Wizard validation UX | `WizardValidationStagePresenter`, `ValidationPipelineDialogMapper` | Stage cards / dialog mapper parity ([PR #110](https://github.com/th3w1zard1/ModSync/pull/110)) | | Version alignment | `ReleaseVersionAlignmentTests` | Release metadata consistency | @@ -85,6 +86,6 @@ Wizard order from `src/ModSync.GUI/Dialogs/InstallWizardDialog.axaml.cs` and `AG 9. **FOMOD post-download** — GUI prompts after Fetch Downloads (PR #169). CLI: TTY wizard, `--fomod-skip`, `--fomod-choices` / `MODSYNC_FOMOD_CHOICES`, or non-TTY **warn-continue** (marks `warned`; `FomodConfigurationGate` still blocks validate/install until `configured`). See [fomod-support.md](fomod-support.md). 10. **Guide drafts** — `--parse-directions` / GUI paste drafts are review-flagged; never treat as trusted install instructions without review. See [guide-ingestion.md](guide-ingestion.md). 11. **Managed / profile CLI** — `install --managed` / `--no-managed` / `--profile` shipped (#177). Profile create/list/delete remains `[UI]` / file edits under `{settingsDir}/profiles/`. See [managed-deployment.md](managed-deployment.md). -12. **Validation presentation vs pipeline** — CLI and GUI share `InstallationValidationPipeline`; stage-card UX / copy-report / go-to-first-issue remain `[UI]` ([gui-validation-surfaces.md](gui-validation-surfaces.md)). +12. **Validation presentation vs pipeline** — CLI and GUI share `InstallationValidationPipeline`; stage-card UX / copy-report / go-to-first-issue remain `[UI]`. Agents use `validate --output json` for structured reports ([gui-validation-surfaces.md](gui-validation-surfaces.md)). See [agent-native-audit.md](agent-native-audit.md) for scored principles and [core-cli-reference.md](core-cli-reference.md) for flags. diff --git a/docs/knowledgebase/agent-native-audit.md b/docs/knowledgebase/agent-native-audit.md index 57b0948d..eda305da 100644 --- a/docs/knowledgebase/agent-native-audit.md +++ b/docs/knowledgebase/agent-native-audit.md @@ -51,7 +51,7 @@ This product is a **desktop mod installer**, not a web agent host. Scores reflec | Set mod / game directories | GUI preload or CLI `-g` / `-s` | Yes | | Paste / ingest guide | GUI clipboard; CLI `convert --stdin` / `-i` + `--parse-directions` | Yes (file/stdin); OS clipboard `[UI]` | | `modsync://` open/install link | `--modsync=` / URI argv → handoff | Yes (consume); Settings toggle deferred | -| Run validation | `ValidatePage` or `validate --full` / `--dry-run` | Yes (selection flags differ) | +| Run validation | `ValidatePage` or `validate --full` / `--dry-run` / `validate --output json` | Yes (selection flags differ) | | Fetch downloads | Wizard / `ScrapeDownloadsButton` | Partial — CLI `install -d` / `convert -d`; status/stop `[UI]` | | Post-download FOMOD configure | GUI after Fetch Downloads | Yes — CLI TTY / `--fomod-choices` / `--fomod-skip` | | FOMOD configure-before-validate/install gate | GUI + Core `FomodConfigurationGate` | Yes — shared fail-closed gate | @@ -62,7 +62,7 @@ This product is a **desktop mod installer**, not a web agent host. Scores reflec | Widescreen-only install block | Dynamic wizard pages | No — desktop only | | Aspyr notice (K2) | `AspyrNoticePage` | No — desktop only | | Rich-text / spoiler UI | GUI controls | No | -| ValidatePage stage cards / copy report | Wizard presentation | Partial — pipeline Full; presentation `[UI]` | +| ValidatePage stage cards / copy report | Wizard presentation | Partial — pipeline Full; `--output json` for machine reports; presentation `[UI]` | | Telemetry-auth sidecar | Separate Python stack | Routed via `telemetry-auth/README.md` | **Strengths:** `[REPO]` `ModBuildConverter` covers validate/install/convert/merge; FOMOD Plan 123 + gate; guide paste (#171); `modsync://` Phase 1–2; managed CLI overrides (#177); `install_best_effort.sh` documents a full-build-style headless path. @@ -156,7 +156,7 @@ Files are the interface: instruction TOMLs, ingested drafts under `tmp/`, `tmp/k 2. **Download status / stop** — CLI can download; live progress and stop controls are `[UI]` only. 3. **Widescreen + Aspyr flows** — K2 wizard-only pages; no CLI equivalent. 4. **Managed dry-run / VFS validation parity** — deferred `[OPEN]` in [managed-deployment.md](managed-deployment.md); classic VFS validate does not fully model managed staging/deploy. -5. **Validation presentation / machine output** — stage-card UX / copy-report / go-to-first-issue remain `[UI]`; no structured JSON validation report for agents. +5. **Validation presentation / machine output** — stage-card UX / copy-report / go-to-first-issue remain `[UI]`; CLI supports `validate --output json` for structured reports. Honorable mentions: OS clipboard paste still `[UI]` (stdin/file ingest covers agents); `modsync://` Settings checkbox deferred. @@ -168,7 +168,7 @@ Honorable mentions: OS clipboard paste still `[UI]` (stdin/file ingest covers ag 2. **Tier 1:** Treat GUI-only flows as `[UI]` in plans; check [agent-action-parity.md](agent-action-parity.md) before assuming headless parity. 3. **Tier 2:** When adding wizard steps, add a CLI or script path—or document the gap in the parity matrix. 4. **Tier 2:** Close managed dry-run/VFS validation parity ([plan 004](../plans/2026-07-13-004-managed-deployment-validation-plan.md)). -5. **Tier 3:** Optional: structured JSON validation output; CLI profile list/create/activate verbs. +5. **Tier 3:** Optional: extend JSON validation schema with per-mod issue IDs; CLI profile list/create/activate verbs. ## Strengths summary diff --git a/docs/knowledgebase/core-cli-reference.md b/docs/knowledgebase/core-cli-reference.md index 589b537f..165bc18b 100644 --- a/docs/knowledgebase/core-cli-reference.md +++ b/docs/knowledgebase/core-cli-reference.md @@ -41,6 +41,7 @@ Validate an instruction file. Structural checks work with `-i` alone; environmen | `--dry-run-only` | No | VFS dry-run only; skips per-component archive existence checks (requires game + source dirs) | | `--errors-only` | No | Suppress warnings/info | | `--ignore-errors` | No | Best-effort dependency order | +| `--output` | No | Output format: `text` (default) or `json` (machine-readable report on stdout) | **Example:** @@ -55,11 +56,15 @@ dotnet run --project src/ModSync.Core/ModSync.Core.csproj -f net9.0 -- \ dotnet run --project src/ModSync.Core/ModSync.Core.csproj -f net9.0 -- \ validate -i ./mod-builds/TOMLs/KOTOR1_Full.toml \ - -g ./tmp/kotor_template -s ./tmp/mod_downloads --dry-run-only + -g ./tmp/kotor_template -s ./tmp/mod_downloads --dry-run-only --output json ``` With an empty mod workspace, `--dry-run` runs archive checks first and often exits non-zero before VFS simulation. Use `--dry-run-only` when you want VFS structural validation without requiring archives on disk. +**JSON output:** `--output json` writes a single JSON document to stdout (stages, counts, dry-run issues). Progress logs are suppressed so agents can parse stdout directly. Early failures (missing input file, missing dirs) also emit JSON with an `error` field. + +**Tests:** `dotnet test src/ModSync.Tests/ModSync.Tests.csproj --filter "FullyQualifiedName~ValidateCliJsonTests|FullyQualifiedName~ValidationPipelineJsonFormatterTests"` + --- ### `install` diff --git a/src/ModSync.Core/CLI/ModBuildConverter.cs b/src/ModSync.Core/CLI/ModBuildConverter.cs index d898b25e..eb4fe65c 100644 --- a/src/ModSync.Core/CLI/ModBuildConverter.cs +++ b/src/ModSync.Core/CLI/ModBuildConverter.cs @@ -596,6 +596,9 @@ public class ValidateOptions : BaseOptions [Option("use-file-selection", Required = false, Default = false, HelpText = "Only validate components with IsSelected=true in the file (matches GUI after Mod Selection). Without this flag and without --select, all components are validated.")] public bool UseFileSelection { get; set; } + + [Option("output", Required = false, Default = "text", HelpText = "Output format: text (default) or json")] + public string Output { get; set; } } [Verb("install", HelpText = "Install mods from an instruction file")] @@ -696,20 +699,60 @@ public static int Run(string[] args) Environment.SetEnvironmentVariable("PYTHON_KEYRING_BACKEND", "keyring.backends.null.Keyring"); // Avoid forcing an empty DISPLAY: some child tools probe X11; leave user/session DISPLAY intact. - Logger.Initialize(); + bool validateJsonOutput = ShouldUseValidateJsonOutput(args); + if (validateJsonOutput) + { + Logger.SetMachineReadableConsoleMode(true); + } - var parser = new Parser(with => with.HelpWriter = Console.Out); + try + { + Logger.Initialize(); + + var parser = new Parser(with => with.HelpWriter = Console.Out); + + return parser.ParseArguments(args) + .MapResult( + (ConvertOptions opts) => RunConvertAsync(opts).GetAwaiter().GetResult(), + (MergeOptions opts) => RunMergeAsync(opts).GetAwaiter().GetResult(), + (ValidateOptions opts) => RunValidateAsync(opts).GetAwaiter().GetResult(), + (InstallOptions opts) => RunInstallAsync(opts).GetAwaiter().GetResult(), + (SetNexusApiKeyOptions opts) => RunSetNexusApiKeyAsync(opts).GetAwaiter().GetResult(), + (InstallPythonDepsOptions opts) => RunInstallPythonDepsAsync(opts).GetAwaiter().GetResult(), + (HolopatcherOptions opts) => RunHolopatcherAsync(opts).GetAwaiter().GetResult(), + errs => 1); + } + finally + { + if (validateJsonOutput) + { + Logger.SetMachineReadableConsoleMode(false); + } + } + } - return parser.ParseArguments(args) - .MapResult( - (ConvertOptions opts) => RunConvertAsync(opts).GetAwaiter().GetResult(), - (MergeOptions opts) => RunMergeAsync(opts).GetAwaiter().GetResult(), - (ValidateOptions opts) => RunValidateAsync(opts).GetAwaiter().GetResult(), - (InstallOptions opts) => RunInstallAsync(opts).GetAwaiter().GetResult(), - (SetNexusApiKeyOptions opts) => RunSetNexusApiKeyAsync(opts).GetAwaiter().GetResult(), - (InstallPythonDepsOptions opts) => RunInstallPythonDepsAsync(opts).GetAwaiter().GetResult(), - (HolopatcherOptions opts) => RunHolopatcherAsync(opts).GetAwaiter().GetResult(), - errs => 1); + private static bool ShouldUseValidateJsonOutput([NotNull] string[] args) + { + if (args.Length == 0 || !string.Equals(args[0], "validate", StringComparison.OrdinalIgnoreCase)) + { + return false; + } + + for (int i = 0; i < args.Length; i++) + { + if (string.Equals(args[i], "--output", StringComparison.OrdinalIgnoreCase) && i + 1 < args.Length) + { + return string.Equals(args[i + 1], "json", StringComparison.OrdinalIgnoreCase); + } + + if (args[i].StartsWith("--output=", StringComparison.OrdinalIgnoreCase)) + { + string value = args[i].Substring("--output=".Length); + return string.Equals(value, "json", StringComparison.OrdinalIgnoreCase); + } + } + + return false; } private static void SetVerboseMode(bool verbose) @@ -2892,16 +2935,40 @@ private static async Task LogValidationPipelineOutputAsync( }; } + private static bool IsValidateJsonOutput([NotNull] ValidateOptions opts) => + string.Equals(opts.Output, "json", StringComparison.OrdinalIgnoreCase); + + private static int WriteValidateJsonError([NotNull] string error, int exitCode = 1) + { + Console.WriteLine(ValidationPipelineJsonFormatter.SerializeError(error, exitCode)); + return exitCode; + } + private static async Task RunValidateAsync(ValidateOptions opts) { SetVerboseMode(opts.Verbose); s_errorCollector = new ErrorCollector(); + bool jsonOutput = IsValidateJsonOutput(opts); + + if (!jsonOutput + && !string.IsNullOrWhiteSpace(opts.Output) + && !string.Equals(opts.Output, "text", StringComparison.OrdinalIgnoreCase)) + { + await Logger.LogErrorAsync($"Error: Unknown validate output format '{opts.Output}'. Use text or json.").ConfigureAwait(false); + return 1; + } try { if (!File.Exists(opts.InputPath)) { - await Logger.LogErrorAsync($"Error: Input file not found: {opts.InputPath}").ConfigureAwait(false); + string message = $"Input file not found: {opts.InputPath}"; + if (jsonOutput) + { + return WriteValidateJsonError(message); + } + + await Logger.LogErrorAsync($"Error: {message}").ConfigureAwait(false); return 1; } @@ -2909,24 +2976,45 @@ private static async Task RunValidateAsync(ValidateOptions opts) { if (string.IsNullOrEmpty(opts.GameDirectory) || string.IsNullOrEmpty(opts.SourceDirectory)) { - await Logger.LogErrorAsync("Error: Full validation and dry-run modes require both --game-dir and --source-dir").ConfigureAwait(false); + const string message = "Full validation and dry-run modes require both --game-dir and --source-dir"; + if (jsonOutput) + { + return WriteValidateJsonError(message); + } + + await Logger.LogErrorAsync($"Error: {message}").ConfigureAwait(false); return 1; } if (!Directory.Exists(opts.GameDirectory)) { - await Logger.LogErrorAsync($"Error: Game directory not found: {opts.GameDirectory}").ConfigureAwait(false); + string message = $"Game directory not found: {opts.GameDirectory}"; + if (jsonOutput) + { + return WriteValidateJsonError(message); + } + + await Logger.LogErrorAsync($"Error: {message}").ConfigureAwait(false); return 1; } if (!Directory.Exists(opts.SourceDirectory)) { - await Logger.LogErrorAsync($"Error: Source directory not found: {opts.SourceDirectory}").ConfigureAwait(false); + string message = $"Source directory not found: {opts.SourceDirectory}"; + if (jsonOutput) + { + return WriteValidateJsonError(message); + } + + await Logger.LogErrorAsync($"Error: {message}").ConfigureAwait(false); return 1; } } - await Logger.LogAsync($"Loading instruction file: {opts.InputPath}").ConfigureAwait(false); + if (!jsonOutput) + { + await Logger.LogAsync($"Loading instruction file: {opts.InputPath}").ConfigureAwait(false); + } List components; try @@ -2938,7 +3026,13 @@ private static async Task RunValidateAsync(ValidateOptions opts) } catch (Exception ex) { - await Logger.LogErrorAsync($"Error loading instruction file: {ex.Message}").ConfigureAwait(false); + string message = $"Error loading instruction file: {ex.Message}"; + if (jsonOutput) + { + return WriteValidateJsonError(message); + } + + await Logger.LogErrorAsync(message).ConfigureAwait(false); if (opts.Verbose) { await Logger.LogErrorAsync("Stack trace:").ConfigureAwait(false); @@ -2957,12 +3051,21 @@ private static async Task RunValidateAsync(ValidateOptions opts) if (components is null || components.Count == 0) { - await Logger.LogErrorAsync("Error: No components loaded from instruction file.").ConfigureAwait(false); + const string message = "No components loaded from instruction file."; + if (jsonOutput) + { + return WriteValidateJsonError(message); + } + + await Logger.LogErrorAsync($"Error: {message}").ConfigureAwait(false); return 1; } - await Logger.LogAsync($"Loaded {components.Count} component(s) from instruction file.").ConfigureAwait(false); - await Logger.LogAsync().ConfigureAwait(false); + if (!jsonOutput) + { + await Logger.LogAsync($"Loaded {components.Count} component(s) from instruction file.").ConfigureAwait(false); + await Logger.LogAsync().ConfigureAwait(false); + } if (opts.FullValidation || opts.DryRun || opts.DryRunOnly) { @@ -2976,7 +3079,7 @@ private static async Task RunValidateAsync(ValidateOptions opts) bool hasExplicitSelect = opts.Select != null && opts.Select.Any(); if (hasExplicitSelect) { - if (!opts.ErrorsOnly) + if (!opts.ErrorsOnly && !jsonOutput) { await Logger.LogAsync("Applying selection filters...").ConfigureAwait(false); } @@ -2987,11 +3090,17 @@ private static async Task RunValidateAsync(ValidateOptions opts) if (componentsToValidate.Count == 0) { - await Logger.LogErrorAsync("Error: No components match the selection criteria.").ConfigureAwait(false); + const string message = "No components match the selection criteria."; + if (jsonOutput) + { + return WriteValidateJsonError(message); + } + + await Logger.LogErrorAsync($"Error: {message}").ConfigureAwait(false); return 1; } - if (!opts.ErrorsOnly) + if (!opts.ErrorsOnly && !jsonOutput) { await Logger.LogAsync($"{componentsToValidate.Count} component(s) selected for validation.").ConfigureAwait(false); } @@ -3001,11 +3110,17 @@ private static async Task RunValidateAsync(ValidateOptions opts) componentsToValidate = components.Where(c => c.IsSelected).ToList(); if (componentsToValidate.Count == 0) { - await Logger.LogErrorAsync("Error: No components are marked IsSelected in the instruction file.").ConfigureAwait(false); + const string message = "No components are marked IsSelected in the instruction file."; + if (jsonOutput) + { + return WriteValidateJsonError(message); + } + + await Logger.LogErrorAsync($"Error: {message}").ConfigureAwait(false); return 1; } - if (!opts.ErrorsOnly) + if (!opts.ErrorsOnly && !jsonOutput) { await Logger.LogAsync($"{componentsToValidate.Count} component(s) marked selected in file will be validated.").ConfigureAwait(false); } @@ -3034,6 +3149,15 @@ private static async Task RunValidateAsync(ValidateOptions opts) components, pipelineOptions).ConfigureAwait(false); + if (jsonOutput) + { + Console.WriteLine(ValidationPipelineJsonFormatter.SerializeReport( + pipelineResult, + componentsToValidate.Count, + opts.InputPath)); + return pipelineResult.ExitCode; + } + await LogValidationPipelineOutputAsync(pipelineResult, componentsToValidate.Count, opts.ErrorsOnly, opts.DryRunOnly).ConfigureAwait(false); if (pipelineResult.ExitCode != 0) @@ -3070,6 +3194,11 @@ await Logger.LogAsync( } catch (Exception ex) { + if (jsonOutput) + { + return WriteValidateJsonError($"Error during validation: {ex.Message}"); + } + await Logger.LogErrorAsync($"Error during validation: {ex.Message}").ConfigureAwait(false); if (opts.Verbose) { diff --git a/src/ModSync.Core/Logger.cs b/src/ModSync.Core/Logger.cs index 911aacad..cb3bfb65 100644 --- a/src/ModSync.Core/Logger.cs +++ b/src/ModSync.Core/Logger.cs @@ -19,6 +19,18 @@ public static class Logger private static readonly object s_initializationLock = new object(); private static readonly NLog.Logger s_logger = LogManager.GetCurrentClassLogger(); + /// + /// When enabled, suppresses console/stderr log mirroring so machine-readable CLI output (e.g. validate --output json) owns stdout. + /// File and memory NLog targets remain active. + /// + public static bool MachineReadableConsoleMode { get; private set; } + + public static void SetMachineReadableConsoleMode(bool enabled) + { + MachineReadableConsoleMode = enabled; + GlobalDiagnosticsContext.Set("SuppressConsoleLogging", enabled ? "true" : "false"); + } + public enum LogType { Info, @@ -75,7 +87,7 @@ private static async Task LogInternalAsync( Logged?.Invoke(logMessage); // Only output to console/error if not fileOnly - if (!fileOnly) + if (!fileOnly && !MachineReadableConsoleMode) { // Output to appropriate destination based on context if (IsRunningInTestContext()) diff --git a/src/ModSync.Core/NLog.config b/src/ModSync.Core/NLog.config index f893ca6b..7fe538e0 100644 --- a/src/ModSync.Core/NLog.config +++ b/src/ModSync.Core/NLog.config @@ -35,13 +35,28 @@ - - + + - - + + + + + + + + + + + + + + + + + diff --git a/src/ModSync.Core/Services/Validation/ValidationPipelineJsonFormatter.cs b/src/ModSync.Core/Services/Validation/ValidationPipelineJsonFormatter.cs new file mode 100644 index 00000000..6044d09f --- /dev/null +++ b/src/ModSync.Core/Services/Validation/ValidationPipelineJsonFormatter.cs @@ -0,0 +1,181 @@ +// Copyright 2021-2025 ModSync +// Licensed under the Business Source License 1.1 (BSL 1.1). +// See LICENSE.txt file in the project root for full license information. + +using System.Collections.Generic; +using System.Linq; +using System.Text.Json; +using System.Text.Json.Serialization; + +using JetBrains.Annotations; + +using ModSync.Core.Services.FileSystem; + +namespace ModSync.Core.Services.Validation +{ + /// + /// Serializes for agent/CLI machine output (validate --output json). + /// + public static class ValidationPipelineJsonFormatter + { + [NotNull] + private static readonly JsonSerializerOptions JsonOptions = new JsonSerializerOptions + { + WriteIndented = true, + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + }; + + [NotNull] + public static string SerializeReport( + [NotNull] ValidationPipelineResult pipelineResult, + int componentCount, + [CanBeNull] string inputPath = null) + { + var report = new ValidationPipelineJsonReport + { + Success = pipelineResult.IsSuccess, + ExitCode = pipelineResult.ExitCode, + ErrorCount = pipelineResult.ErrorCount, + WarningCount = pipelineResult.WarningCount, + PassedCount = pipelineResult.PassedCount, + ComponentCount = componentCount, + InputPath = inputPath, + Stages = pipelineResult.Stages.Select(MapStage).ToList(), + DryRun = pipelineResult.DryRunResult is null ? null : MapDryRun(pipelineResult.DryRunResult), + }; + + return JsonSerializer.Serialize(report, JsonOptions); + } + + [NotNull] + public static string SerializeError([NotNull] string error, int exitCode = 1) + { + var report = new ValidationPipelineJsonReport + { + Success = false, + ExitCode = exitCode, + Error = error, + }; + + return JsonSerializer.Serialize(report, JsonOptions); + } + + [NotNull] + private static ValidationPipelineJsonStage MapStage([NotNull] ValidationPipelineStageResult stage) + { + return new ValidationPipelineJsonStage + { + Stage = stage.Stage.ToString(), + Passed = stage.Passed, + HasWarnings = stage.HasWarnings, + Summary = stage.Summary, + Messages = stage.Messages.Count == 0 ? null : new List(stage.Messages), + }; + } + + [NotNull] + private static ValidationPipelineJsonDryRun MapDryRun([NotNull] DryRunValidationResult dryRun) + { + return new ValidationPipelineJsonDryRun + { + IsValid = dryRun.IsValid, + HasWarnings = dryRun.HasWarnings, + Issues = dryRun.Issues.Select(MapIssue).ToList(), + }; + } + + [NotNull] + private static ValidationPipelineJsonIssue MapIssue([NotNull] ValidationIssue issue) + { + return new ValidationPipelineJsonIssue + { + Severity = issue.Severity.ToString(), + Category = issue.Category, + Message = issue.Message, + AffectedPath = issue.AffectedPath, + ComponentName = issue.AffectedComponent?.Name, + ComponentGuid = issue.AffectedComponent?.Guid.ToString(), + InstructionIndex = issue.InstructionIndex > 0 ? issue.InstructionIndex : (int?)null, + Solution = issue.Solution, + }; + } + } + + public sealed class ValidationPipelineJsonReport + { + public bool Success { get; set; } + + public int ExitCode { get; set; } + + public int ErrorCount { get; set; } + + public int WarningCount { get; set; } + + public int PassedCount { get; set; } + + public int ComponentCount { get; set; } + + [CanBeNull] + public string InputPath { get; set; } + + [CanBeNull] + public string Error { get; set; } + + [CanBeNull] + public List Stages { get; set; } + + [CanBeNull] + public ValidationPipelineJsonDryRun DryRun { get; set; } + } + + public sealed class ValidationPipelineJsonStage + { + public string Stage { get; set; } + + public bool Passed { get; set; } + + public bool HasWarnings { get; set; } + + [CanBeNull] + public string Summary { get; set; } + + [CanBeNull] + public List Messages { get; set; } + } + + public sealed class ValidationPipelineJsonDryRun + { + public bool IsValid { get; set; } + + public bool HasWarnings { get; set; } + + [CanBeNull] + public List Issues { get; set; } + } + + public sealed class ValidationPipelineJsonIssue + { + public string Severity { get; set; } + + [CanBeNull] + public string Category { get; set; } + + [CanBeNull] + public string Message { get; set; } + + [CanBeNull] + public string AffectedPath { get; set; } + + [CanBeNull] + public string ComponentName { get; set; } + + [CanBeNull] + public string ComponentGuid { get; set; } + + public int? InstructionIndex { get; set; } + + [CanBeNull] + public string Solution { get; set; } + } +} diff --git a/src/ModSync.Tests/ValidateCliJsonTests.cs b/src/ModSync.Tests/ValidateCliJsonTests.cs new file mode 100644 index 00000000..ae978b56 --- /dev/null +++ b/src/ModSync.Tests/ValidateCliJsonTests.cs @@ -0,0 +1,175 @@ +// Copyright 2021-2025 ModSync +// Licensed under the Business Source License 1.1 (BSL 1.1). +// See LICENSE.txt file in the project root for full license information. + +using System; +using System.IO; +using System.Runtime.InteropServices; +using System.Text.Json; + +using ModSync.Core; +using ModSync.Core.CLI; + +using NUnit.Framework; + +namespace ModSync.Tests +{ + [TestFixture] + public sealed class ValidateCliJsonTests + { + private string _tempDir; + + [SetUp] + public void SetUp() + { + _tempDir = Path.Combine(Path.GetTempPath(), "ModSync_ValidateJson_" + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(_tempDir); + + string gameDir = Path.Combine(_tempDir, "game"); + string modDir = Path.Combine(_tempDir, "mods"); + Directory.CreateDirectory(gameDir); + Directory.CreateDirectory(modDir); + File.WriteAllText(Path.Combine(gameDir, "swkotor.exe"), string.Empty); + File.WriteAllText(Path.Combine(modDir, "payload.txt"), "payload"); + + MainConfig.Instance = new MainConfig + { + destinationPath = new DirectoryInfo(gameDir), + sourcePath = new DirectoryInfo(modDir), + }; + + EnsureHolopatcherInTestResources(); + } + + [TearDown] + public void TearDown() + { + if (Directory.Exists(_tempDir)) + { + Directory.Delete(_tempDir, recursive: true); + } + } + + [Test] + public void Validate_OutputJson_DryRunOnly_WritesOnlyJsonDocumentToStdout() + { + string componentGuid = Guid.NewGuid().ToString(); + string instructionGuid = Guid.NewGuid().ToString(); + string tomlPath = Path.Combine(_tempDir, "dry_run_mod.toml"); + File.WriteAllText( + tomlPath, + $@"[[thisMod]] +Guid = ""{componentGuid}"" +Name = ""JsonDryRunMod"" +IsSelected = true +Tier = ""1 - Essential"" +Category = [""Test""] + +[[thisMod.Instructions]] +Guid = ""{instructionGuid}"" +Action = ""Move"" +Source = [""<>\\payload.txt""] +Destination = ""<>\\Override"" +"); + + string gameDir = MainConfig.DestinationPath.FullName; + string modDir = MainConfig.SourcePath.FullName; + + int exitCode = RunValidateWithCapturedStdout(new[] + { + "validate", + "-i", tomlPath, + "-g", gameDir, + "-s", modDir, + "--dry-run-only", + "--output", "json", + "--use-file-selection", + }, out string stdout); + + Assert.That(stdout, Does.StartWith("{")); + Assert.That(stdout, Does.EndWith("}")); + + using JsonDocument document = JsonDocument.Parse(stdout); + JsonElement root = document.RootElement; + + Assert.Multiple(() => + { + Assert.That(exitCode, Is.EqualTo(0)); + Assert.That(root.GetProperty("success").GetBoolean(), Is.True); + Assert.That(root.GetProperty("inputPath").GetString(), Is.EqualTo(tomlPath)); + Assert.That(root.GetProperty("stages").GetArrayLength(), Is.GreaterThan(0)); + }); + } + + [Test] + public void Validate_OutputJson_MissingInputFile_ReturnsErrorEnvelope() + { + string missingPath = Path.Combine(_tempDir, "definitely_missing.toml"); + + int exitCode = RunValidateWithCapturedStdout(new[] + { + "validate", + "-i", missingPath, + "--output", "json", + }, out string stdout); + + Assert.That(stdout, Does.StartWith("{")); + + using JsonDocument document = JsonDocument.Parse(stdout); + JsonElement root = document.RootElement; + + Assert.Multiple(() => + { + Assert.That(exitCode, Is.EqualTo(1)); + Assert.That(root.GetProperty("success").GetBoolean(), Is.False); + Assert.That(root.GetProperty("error").GetString(), Does.Contain("definitely_missing.toml")); + }); + } + + private static int RunValidateWithCapturedStdout(string[] args, out string stdout) + { + var writer = new StringWriter(); + TextWriter previousOut = Console.Out; + Console.SetOut(writer); + try + { + return ModBuildConverter.Run(args); + } + finally + { + Console.SetOut(previousOut); + stdout = writer.ToString().Trim(); + } + } + + private static void EnsureHolopatcherInTestResources() + { + string baseDir = AppDomain.CurrentDomain.BaseDirectory; + string resourcesDir = Path.Combine(baseDir, "Resources"); + Directory.CreateDirectory(resourcesDir); + string targetPath = Path.Combine(resourcesDir, "holopatcher"); + if (File.Exists(targetPath)) + { + return; + } + + string vendorHolopatcher = Path.GetFullPath(Path.Combine( + baseDir, + "..", "..", "..", "..", "..", + "vendor", "bin", "HoloPatcher_linux")); + if (!File.Exists(vendorHolopatcher)) + { + return; + } + + if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) + { + File.Copy(vendorHolopatcher, targetPath, overwrite: true); + } + else + { + File.CreateSymbolicLink(targetPath, vendorHolopatcher); + } + } + } +} diff --git a/src/ModSync.Tests/ValidationPipelineJsonFormatterTests.cs b/src/ModSync.Tests/ValidationPipelineJsonFormatterTests.cs new file mode 100644 index 00000000..92048161 --- /dev/null +++ b/src/ModSync.Tests/ValidationPipelineJsonFormatterTests.cs @@ -0,0 +1,67 @@ +// Copyright 2021-2025 ModSync +// Licensed under the Business Source License 1.1 (BSL 1.1). +// See LICENSE.txt file in the project root for full license information. + +using System; +using System.Text.Json; + +using ModSync.Core.Services.Validation; + +using NUnit.Framework; + +namespace ModSync.Tests +{ + [TestFixture] + public sealed class ValidationPipelineJsonFormatterTests + { + [Test] + public void SerializeReport_IncludesStageSummaries() + { + var pipelineResult = new ValidationPipelineResult + { + IsSuccess = false, + ErrorCount = 1, + WarningCount = 0, + PassedCount = 0, + }; + pipelineResult.Stages.Add(new ValidationPipelineStageResult + { + Stage = ValidationPipelineStage.ComponentValidation, + Passed = false, + Summary = "1 component failed validation", + Messages = { "ERROR: MissingArchiveMod: archive missing" }, + }); + + string json = ValidationPipelineJsonFormatter.SerializeReport(pipelineResult, componentCount: 1, inputPath: "test.toml"); + using JsonDocument document = JsonDocument.Parse(json); + JsonElement root = document.RootElement; + + Assert.Multiple(() => + { + Assert.That(root.GetProperty("success").GetBoolean(), Is.False); + Assert.That(root.GetProperty("exitCode").GetInt32(), Is.EqualTo(1)); + Assert.That(root.GetProperty("componentCount").GetInt32(), Is.EqualTo(1)); + Assert.That(root.GetProperty("inputPath").GetString(), Is.EqualTo("test.toml")); + JsonElement stages = root.GetProperty("stages"); + Assert.That(stages.GetArrayLength(), Is.EqualTo(1)); + Assert.That(stages[0].GetProperty("stage").GetString(), Is.EqualTo("ComponentValidation")); + Assert.That(stages[0].GetProperty("passed").GetBoolean(), Is.False); + }); + } + + [Test] + public void SerializeError_ReturnsFailureEnvelope() + { + string json = ValidationPipelineJsonFormatter.SerializeError("Input file not found: missing.toml"); + using JsonDocument document = JsonDocument.Parse(json); + JsonElement root = document.RootElement; + + Assert.Multiple(() => + { + Assert.That(root.GetProperty("success").GetBoolean(), Is.False); + Assert.That(root.GetProperty("exitCode").GetInt32(), Is.EqualTo(1)); + Assert.That(root.GetProperty("error").GetString(), Does.Contain("missing.toml")); + }); + } + } +}