using System.IO; using System.Text.Json; using CliFx; using CliFx.Attributes; using CliFx.Exceptions; using CliFx.Infrastructure; using ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Import; namespace ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Cli.Commands; /// /// ablegacy-11 / #254 — read an RSLogix 500 / 5 "Database Export" CSV and emit either an /// appsettings.json tag fragment or a summary line. Avoids the AbLegacyCommandBase /// hierarchy because import is a purely-offline operation: no gateway, no driver, no /// timeout. Mirrors the /// #254 plan section's CLI /// specification verbatim. /// [Command("import-rslogix", Description = "Read an RSLogix 500/5 CSV symbol export and emit a JSON tag fragment for appsettings.json. " + "Binary .RSS / .RSP project files are out of scope (see docs/drivers/AbLegacy-RSLogix-Import.md).")] public sealed class ImportRslogixCommand : ICommand { [CommandOption("file", 'f', Description = "Path to the RSLogix CSV export. RFC 4180-ish format with header columns " + "Symbol,Address,Description,DataType,Scope; quoted fields + doubled-quote escapes " + "are honoured; comment lines starting with ; or # are skipped.", IsRequired = true)] public string File { get; init; } = default!; [CommandOption("device", 'd', Description = "Canonical AB Legacy gateway URI (ab://host[:port]/cip-path) every imported tag " + "binds to. Required even though import is offline — the resulting tag definitions " + "carry the gateway address as their DeviceHostAddress.", IsRequired = true)] public string Device { get; init; } = default!; [CommandOption("emit", Description = "Output shape: 'appsettings-fragment' (default) emits a JSON object with a Tags array " + "ready to paste into appsettings.json; 'summary' emits one human-readable counter line.")] public string Emit { get; init; } = "appsettings-fragment"; [CommandOption("output", 'o', Description = "Optional output file. When omitted, the result goes to stdout.")] public string? Output { get; init; } [CommandOption("scope", Description = "Optional Scope filter — match the row's Scope column against this value " + "case-insensitively. Common values: 'Global', 'Local:1', 'Local:2'. Rows with " + "no Scope column count as Global.")] public string? Scope { get; init; } [CommandOption("max-rows", Description = "Defensive cap on the number of rows imported. Beyond the cap the parser stops " + "and emits a warning; useful for dry-running a large export against the CLI.")] public int? MaxRows { get; init; } [CommandOption("strict", Description = "When set, the first malformed row throws and the CLI exits non-zero. Default is " + "permissive (skip + log).")] public bool Strict { get; init; } public async ValueTask ExecuteAsync(IConsole console) { if (!System.IO.File.Exists(File)) { // Surface a clean exit-code-1 with a one-line error rather than letting // FileNotFoundException bubble up through CliFx's default exception path — // the CLI tests and operators both prefer `import-rslogix --file missing.csv` // to print "file not found" rather than a stack trace. throw new CommandException($"RSLogix CSV not found: {File}", exitCode: 1); } var opts = new ImportOptions( ScopeFilter: Scope, MaxRowsToImport: MaxRows, IgnoreInvalid: !Strict); RsLogixImportResult result; using (var stream = System.IO.File.OpenRead(File)) { var importer = new RsLogixSymbolImport(); result = importer.Parse(stream, Device, opts); } var emit = Emit?.Trim().ToLowerInvariant(); var payload = emit switch { "summary" => FormatSummary(result), "appsettings-fragment" or null or "" => FormatFragment(result), _ => throw new CommandException( $"Unknown --emit value '{Emit}'. Use 'appsettings-fragment' or 'summary'.", exitCode: 2), }; if (Output is { Length: > 0 }) { await System.IO.File.WriteAllTextAsync(Output, payload); await console.Output.WriteLineAsync( $"Wrote {result.ParsedCount} tag(s) to {Output} (skipped={result.SkippedCount}, errors={result.ErrorCount})."); } else { await console.Output.WriteLineAsync(payload); } } /// /// Serialise the imported tag list as a JSON fragment shaped like the /// AbLegacyDriverConfigDto's Tags array — drop straight into the /// appsettings.json driver config under /// Drivers/<instance>/Config/Tags. /// internal static string FormatFragment(RsLogixImportResult result) { var tags = result.Tags.Select(t => new { Name = t.Name, DeviceHostAddress = t.DeviceHostAddress, Address = t.Address, DataType = t.DataType.ToString(), Writable = t.Writable, }).ToArray(); var doc = new { Tags = tags }; return JsonSerializer.Serialize(doc, new JsonSerializerOptions { WriteIndented = true }); } private static string FormatSummary(RsLogixImportResult result) => $"Imported {result.ParsedCount} tag(s), skipped {result.SkippedCount}, errors {result.ErrorCount}."; }