using System.CommandLine;
using System.CommandLine.Parsing;
using System.Text.Json;
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Management;
namespace ZB.MOM.WW.ScadaBridge.CLI.Commands;
internal static class CommandHelpers
{
///
/// Resolves the management URL, credentials, and output format, then sends
/// to the management API and returns the process exit code.
///
/// Parsed command-line result from which option values are read.
/// Option that supplies the management URL override.
/// Option that supplies the output format override.
/// Option that supplies the username override.
/// Option that supplies the password override.
/// The management command object to send.
///
/// Optional per-command HTTP timeout. Defaults to 30s, matching the management API's
/// own request timeout. Larger payloads (e.g. Transport bundles) should supply a
/// longer value.
///
///
/// Optional success handler. When supplied, the helper invokes it with the success
/// body instead of running the default rendering path —
/// useful when the caller needs to capture the response (e.g. write a file) rather
/// than print it. The authorization-failure exit-code contract
/// () is preserved on the error path either way,
/// closing CLI-017's regression.
///
internal static async Task ExecuteCommandAsync(
ParseResult result,
Option urlOption,
Option formatOption,
Option usernameOption,
Option passwordOption,
object command,
TimeSpan? timeout = null,
Func? onSuccess = null)
{
var config = CliConfig.Load();
var format = ResolveFormat(result, formatOption, config);
// Resolve management URL
var url = result.GetValue(urlOption);
if (string.IsNullOrWhiteSpace(url))
url = config.ManagementUrl;
if (string.IsNullOrWhiteSpace(url))
{
OutputFormatter.WriteError(
"No management URL specified. Use --url, set SCADABRIDGE_MANAGEMENT_URL, or add 'managementUrl' to ~/.scadabridge/config.json.",
"NO_URL");
return 1;
}
if (!IsValidManagementUrl(url))
{
OutputFormatter.WriteError(
$"Invalid management URL '{url}'. Expected an absolute http/https URL (e.g. http://localhost:9001).",
"INVALID_URL");
return 1;
}
// Resolve credentials: command-line options take precedence, then the
// SCADABRIDGE_USERNAME / SCADABRIDGE_PASSWORD environment variables.
var username = ResolveCredential(result.GetValue(usernameOption), config.Username);
var password = ResolveCredential(result.GetValue(passwordOption), config.Password);
if (string.IsNullOrWhiteSpace(username) || string.IsNullOrWhiteSpace(password))
{
OutputFormatter.WriteError(
"Credentials required. Use --username/--password or set SCADABRIDGE_USERNAME/SCADABRIDGE_PASSWORD.",
"NO_CREDENTIALS");
return 1;
}
// Derive command name from type
var commandName = ManagementCommandRegistry.GetCommandName(command.GetType());
// Send via HTTP
using var client = new ManagementHttpClient(url, username, password);
var response = await client.SendCommandAsync(commandName, command, timeout ?? TimeSpan.FromSeconds(30));
// Caller-supplied success handler short-circuits the default rendering — but
// the error path still routes through IsAuthorizationFailure so the documented
// exit-2 contract holds whether or not a custom handler is provided
// (CLI-017 unification of the bundle path).
if (onSuccess is not null)
{
if (response.JsonData is not null)
return onSuccess(response.JsonData);
OutputFormatter.WriteError(response.Error ?? "Unknown error", response.ErrorCode ?? "ERROR");
return IsAuthorizationFailure(response) ? 2 : 1;
}
return HandleResponse(response, format);
}
///
/// Resolves the output format using the documented precedence chain:
/// an explicitly supplied --format option wins, otherwise the
/// config-file / environment-variable default ()
/// is used, otherwise json. The --format option must not declare a
/// DefaultValueFactory — that would mask whether the flag was supplied.
///
/// Parsed command-line result.
/// The --format option definition.
/// Loaded CLI configuration providing the default format fallback.
internal static string ResolveFormat(ParseResult result, Option formatOption, CliConfig config)
{
// GetResult returns non-null only when the option was actually present on the
// command line, letting an explicit --format override the config default.
if (result.GetResult(formatOption) != null)
{
var explicitValue = result.GetValue(formatOption);
if (!string.IsNullOrWhiteSpace(explicitValue))
return explicitValue;
}
return string.IsNullOrWhiteSpace(config.DefaultFormat) ? "json" : config.DefaultFormat;
}
///
/// Resolves a single credential: an explicit command-line value wins, otherwise the
/// environment-variable fallback (from ) is used.
///
/// Value supplied on the command line, or null if absent.
/// Fallback value from the config file or environment variable.
internal static string? ResolveCredential(string? commandLineValue, string? envValue)
=> string.IsNullOrWhiteSpace(commandLineValue) ? envValue : commandLineValue;
///
/// Validates that a management URL is an absolute http/https URL. A malformed URL
/// (missing scheme, empty, or a non-http scheme) would otherwise reach
/// new Uri(...) in the constructor and throw
/// an unhandled .
///
/// URL string to validate.
internal static bool IsValidManagementUrl(string? url)
{
if (string.IsNullOrWhiteSpace(url))
return false;
return Uri.TryCreate(url, UriKind.Absolute, out var uri)
&& (uri.Scheme == Uri.UriSchemeHttp || uri.Scheme == Uri.UriSchemeHttps);
}
///
/// Writes the management response to stdout and returns the appropriate process exit code.
///
/// Response received from the management API.
/// Output format (json or table).
internal static int HandleResponse(ManagementResponse response, string format)
{
if (response.JsonData != null)
{
// A success status with an empty/whitespace body (e.g. a 204 from a delete)
// is a "command succeeded, no output" case — do not attempt to parse it.
if (string.IsNullOrWhiteSpace(response.JsonData))
{
Console.WriteLine("(ok)");
return 0;
}
if (string.Equals(format, "table", StringComparison.OrdinalIgnoreCase))
{
WriteAsTable(response.JsonData);
}
else
{
Console.WriteLine(response.JsonData);
}
return 0;
}
var errorCode = response.ErrorCode ?? "ERROR";
var error = response.Error ?? "Unknown error";
OutputFormatter.WriteError(error, errorCode);
return IsAuthorizationFailure(response) ? 2 : 1;
}
///
/// Determines whether an error response represents an authorization failure
/// (insufficient role), which the documented exit-code table maps to exit code 2.
/// An HTTP 403 status is the primary signal; the server may also signal it via an
/// UNAUTHORIZED / FORBIDDEN error code on a different HTTP status, so
/// both channels are honoured. (Authentication failure — HTTP 401 / bad credentials
/// — is deliberately not treated as authorization failure; it is exit 1.)
///
internal static bool IsAuthorizationFailure(ManagementResponse response)
{
if (response.StatusCode == 403)
return true;
return string.Equals(response.ErrorCode, "FORBIDDEN", StringComparison.OrdinalIgnoreCase)
|| string.Equals(response.ErrorCode, "UNAUTHORIZED", StringComparison.OrdinalIgnoreCase);
}
private static void WriteAsTable(string json)
{
JsonDocument doc;
try
{
doc = JsonDocument.Parse(json);
}
catch (JsonException)
{
// The server returned a success status but a non-JSON body (e.g. a proxy
// HTML error page, or a plain-text message). Print it verbatim rather than
// crashing — mirrors the raw-body fallback on the JSON path.
Console.WriteLine(json);
return;
}
using (doc)
{
var root = doc.RootElement;
if (root.ValueKind == JsonValueKind.Array)
{
var items = root.EnumerateArray().ToList();
if (items.Count == 0)
{
Console.WriteLine("(no results)");
return;
}
// Derive the header set as the union of property names across *every*
// element, in first-seen order. Using only items[0] would silently drop
// columns for any later element with a different shape (CLI-016).
var objectItems = items.Where(i => i.ValueKind == JsonValueKind.Object).ToList();
string[] headers;
if (objectItems.Count > 0)
{
var seen = new List();
var known = new HashSet(StringComparer.Ordinal);
foreach (var item in objectItems)
foreach (var prop in item.EnumerateObject())
if (known.Add(prop.Name))
seen.Add(prop.Name);
headers = seen.ToArray();
}
else
{
headers = new[] { "Value" };
}
var rows = items.Select(item =>
{
if (item.ValueKind == JsonValueKind.Object)
{
return headers.Select(h =>
item.TryGetProperty(h, out var val)
? val.ValueKind == JsonValueKind.Null ? "" : val.ToString()
: "").ToArray();
}
return new[] { item.ToString() };
});
OutputFormatter.WriteTable(rows, headers);
}
else if (root.ValueKind == JsonValueKind.Object)
{
var headers = new[] { "Property", "Value" };
var rows = root.EnumerateObject().Select(p =>
new[] { p.Name, p.Value.ValueKind == JsonValueKind.Null ? "" : p.Value.ToString() });
OutputFormatter.WriteTable(rows, headers);
}
else
{
Console.WriteLine(root.ToString());
}
}
}
}