# Code Review — Driver.S7.Cli

| Field | Value |
|---|---|
| Module | `src/Drivers/Cli/ZB.MOM.WW.OtOpcUa.Driver.S7.Cli` |
| Reviewer | Claude Code |
| Review date | 2026-06-19 |
| Commit reviewed | `111d6983` |
| Status | Reviewed |
| Open findings | 0 |

## Checklist coverage

A comprehensive review completes every category, recording "No issues found" where
a category produced nothing rather than leaving it blank.

| # | Category | Result |
|---|---|---|
| 1 | Correctness & logic bugs | Driver.S7.Cli-001, Driver.S7.Cli-002 |
| 2 | OtOpcUa conventions | No issues found |
| 3 | Concurrency & thread safety | No issues found |
| 4 | Error handling & resilience | Driver.S7.Cli-001, Driver.S7.Cli-003 |
| 5 | Security | No issues found |
| 6 | Performance & resource management | Driver.S7.Cli-004 |
| 7 | Design-document adherence | Driver.S7.Cli-002 |
| 8 | Code organization & conventions | Driver.S7.Cli-005 |
| 9 | Testing coverage | Driver.S7.Cli-006 |
| 10 | Documentation & comments | Driver.S7.Cli-002, Driver.S7.Cli-007 |

## Findings

### Driver.S7.Cli-001

| Field | Value |
|---|---|
| Severity | Medium |
| Category | Error handling & resilience |
| Location | `src/Drivers/Cli/ZB.MOM.WW.OtOpcUa.Driver.S7.Cli/Commands/WriteCommand.cs:65-80` |
| Status | Resolved |

**Description:** `WriteCommand.ParseValue` parses numeric and `DateTime` values with the
raw BCL parsers (`short.Parse`, `float.Parse`, `DateTime.Parse`, etc.). On malformed
input these throw `FormatException` / `OverflowException`, which are *not*
`CliFx.Exceptions.CommandException`. CliFx renders a `CommandException` as a clean
one-line error with a non-zero exit code, but renders any other exception as a full
.NET stack trace. The `ParseValue` bool path is handled correctly (it throws
`CommandException` for unrecognised input), so the command is internally inconsistent:
`write -t Bool -v maybe` gives a friendly message while `write -t Int16 -v xyz` dumps a
stack trace. The module own test `ParseValue_non_numeric_for_numeric_types_throws`
asserts the raw `FormatException` leaks, confirming the behaviour is unintended-but-shipped.

**Recommendation:** Wrap the numeric / `DateTime` parses in a `try`/`catch` that
re-throws `FormatException` and `OverflowException` as
`CliFx.Exceptions.CommandException` with a message that names the `--type` and the
offending value — matching the bool path. Update the test to expect `CommandException`.

**Resolution:** Resolved 2026-05-22 — wrapped all numeric/DateTime BCL parses in `try/catch(FormatException)` and `try/catch(OverflowException)` that re-throw as `CommandException` with a message naming the `--type` and the offending value; updated `ParseValue_non_numeric_for_numeric_types_throws` to assert `CommandException`, and added an overflow-edge test.

### Driver.S7.Cli-002

| Field | Value |
|---|---|
| Severity | Medium |
| Category | Design-document adherence |
| Location | `src/Drivers/Cli/ZB.MOM.WW.OtOpcUa.Driver.S7.Cli/Commands/ReadCommand.cs:22-29`, `Commands/WriteCommand.cs:21-33`, `Commands/SubscribeCommand.cs:18-21`; `docs/Driver.S7.Cli.md:70-73,80-81` |
| Status | Resolved |

**Description:** The `--type` option help text on `read`, `write`, and `subscribe`
advertises the full `S7DataType` set (`Int64 / UInt64 / Float64 / String / DateTime`),
and `docs/Driver.S7.Cli.md` shows a worked `read ... -t String --string-length 80`
example plus a `--string-length` flag on `read`/`write`. The underlying `S7Driver`
(`S7Driver.cs:241-245` for reads, `:316-320` for writes) throws `NotSupportedException`
for `Int64`, `UInt64`, `Float64`, `String`, and `DateTime` — the driver maps that to
`BadNotSupported`. Consequently every CLI invocation using one of those types — and the
documented `--string-length` string-read example — fails at runtime with
`0x803D0000 (Bad)`. The CLI surface and docs promise capability the driver does not yet
implement.

**Recommendation:** Either (a) trim the `--type` help text and the `--string-length`
flag/examples to the implemented set (`Bool / Byte / Int16 / UInt16 / Int32 / UInt32 /
Float32`) until the follow-up driver PR lands, or (b) keep the surface but add a one-line
"types beyond Float32 are not yet implemented and surface BadNotSupported" caveat to the
help text and `docs/Driver.S7.Cli.md`. Option (a) is preferred so the CLI does not offer
options that cannot succeed.

**Resolution:** Resolved 2026-05-22 — updated the `--type` help text on `read`, `write`, and `subscribe` to list the implemented set (Bool/Byte/Int16/UInt16/Int32/UInt32/Float32) and appended a one-line caveat that Int64/UInt64/Float64/String/DateTime are not yet implemented and will return BadNotSupported.

### Driver.S7.Cli-003

| Field | Value |
|---|---|
| Severity | Medium |
| Category | Error handling & resilience |
| Location | `src/Drivers/Cli/ZB.MOM.WW.OtOpcUa.Driver.S7.Cli/Commands/ProbeCommand.cs:38-50` |
| Status | Resolved |

**Description:** `ProbeCommand` XML doc and the `Driver.S7.Cli.md` "fastest is the
device talking" framing say the probe "connects ... prints health" and "surfaces
`BadNotSupported`" when PUT/GET is disabled. But when the PLC is unreachable (connection
refused, host down, wrong slot), `driver.InitializeAsync` throws and the exception
propagates straight out of `ExecuteAsync` — the code that prints `Host:`, `Health:`,
`Last error:`, and the snapshot is never reached. The most common probe failure (device
not reachable at all) therefore produces a CliFx stack trace rather than the structured
health report the command exists to give. Note PUT/GET-disabled only surfaces during
`ReadAsync` (after a successful connect), so that one path does reach the health print —
but a refused TCP connect does not.

**Recommendation:** Wrap the `InitializeAsync` + `ReadAsync` body in a `try`/`catch` that,
on failure, still prints the `Host:` / `CPU:` lines and a `Health:` / `Last error:`
report derived from `driver.GetHealth()` (which `InitializeAsync` sets to
`Faulted` with the exception message before re-throwing). The probe should report an
unreachable device, not crash on it.

**Resolution:** Resolved 2026-05-22 — wrapped the `InitializeAsync` + `ReadAsync` body in a `try/catch` that on any non-cancellation failure still prints the structured `Host:`, `CPU:`, `Health:`, and `Last error:` lines derived from `driver.GetHealth()`, so an unreachable device produces a health report rather than a stack trace.

### Driver.S7.Cli-004

| Field | Value |
|---|---|
| Severity | Low |
| Category | Performance & resource management |
| Location | `src/Drivers/Cli/ZB.MOM.WW.OtOpcUa.Driver.S7.Cli/Commands/ProbeCommand.cs:36,53`, `Commands/ReadCommand.cs:45,54`, `Commands/WriteCommand.cs:51,60`, `Commands/SubscribeCommand.cs:39,73` |
| Status | Resolved |

**Description:** Every command declares the driver with `await using var driver = new
S7Driver(...)` and *also* calls `await driver.ShutdownAsync(...)` in a `finally` block.
`S7Driver.DisposeAsync` itself calls `ShutdownAsync`, so shutdown runs twice per command
(three times for `subscribe`, which also unsubscribes). `ShutdownAsync` is idempotent
(`Plc?.Close()` is best-effort, `_subscriptions` is cleared) so there is no functional
bug, but the explicit `finally`-block `ShutdownAsync` call is redundant given the
`await using`. It is also slightly misleading — a reader may assume the `await using` is
not actually disposing.

**Recommendation:** Drop the explicit `await driver.ShutdownAsync(...)` from the
`finally` blocks and rely on `await using` for teardown; keep only the
`subscribe` command `UnsubscribeAsync`. Alternatively drop `await using`
and keep the explicit `finally`. Pick one disposal mechanism per command.

**Resolution:** Resolved 2026-05-23 — dropped the explicit `await driver.ShutdownAsync(CancellationToken.None)` calls from the `finally` blocks of `ProbeCommand`, `ReadCommand`, `WriteCommand`, and `SubscribeCommand`; `await using` is now the sole driver-disposal mechanism per command (DisposeAsync itself runs ShutdownAsync), and the subscribe command keeps `UnsubscribeAsync` in its finally because that is a subscription-lifecycle concern, not driver disposal. Added `CommandDisposalConventionsTests` to guard the source-level convention against regression.

### Driver.S7.Cli-005

| Field | Value |
|---|---|
| Severity | Low |
| Category | Code organization & conventions |
| Location | `tests/ZB.MOM.WW.OtOpcUa.Driver.S7.Cli.Tests/` |
| Status | Resolved |

**Description:** A stale directory `tests/ZB.MOM.WW.OtOpcUa.Driver.S7.Cli.Tests/`
exists containing only an `obj/` folder — no `.csproj`, no source. The real test
project lives at `tests/Drivers/Cli/ZB.MOM.WW.OtOpcUa.Driver.S7.Cli.Tests/`. The empty
directory is a leftover from the project move into `tests/Drivers/Cli/` and is not
referenced by `ZB.MOM.WW.OtOpcUa.slnx`. It is dead clutter that can mislead anyone
grepping the tree for the S7 CLI test project.

**Recommendation:** Delete the stale `tests/ZB.MOM.WW.OtOpcUa.Driver.S7.Cli.Tests/`
directory (including its `obj/`). This is outside the module `src/` tree but is the
S7 CLI own orphaned test folder, so it belongs to this module cleanup.

**Resolution:** Resolved 2026-05-23 — deleted the stale `tests/ZB.MOM.WW.OtOpcUa.Driver.S7.Cli.Tests/` directory (only contained a leftover `obj/` from before the move into `tests/Drivers/Cli/`; no tracked files). The real test project at `tests/Drivers/Cli/ZB.MOM.WW.OtOpcUa.Driver.S7.Cli.Tests/` is untouched.

### Driver.S7.Cli-006

| Field | Value |
|---|---|
| Severity | Low |
| Category | Testing coverage |
| Location | `tests/Drivers/Cli/ZB.MOM.WW.OtOpcUa.Driver.S7.Cli.Tests/WriteCommandParseValueTests.cs` |
| Status | Resolved |

**Description:** The only test file covers `WriteCommand.ParseValue` and
`ReadCommand.SynthesiseTagName`. `S7CommandBase.BuildOptions` — which maps the
host / port / CPU / rack / slot / timeout flags onto an `S7DriverOptions` and forces
`Probe.Enabled = false` — has no test, despite being pure, deterministic, and
`internal`-visible to the test assembly via `InternalsVisibleTo`. A regression that
dropped `Probe = new S7ProbeOptions { Enabled = false }` (which would start an
unwanted background probe loop in a one-shot CLI run) or mis-mapped `TimeoutMs` would
not be caught. `ParseValue` is also missing an explicit overflow-edge test (e.g.
`Byte` value `256`) — the current `ParseValue_Byte_ranges` test stops at `255`.

**Recommendation:** Add a `BuildOptions` test (assert `Probe.Enabled == false`,
`Timeout` matches `TimeoutMs`, and host/port/CPU/rack/slot flow through). Add an
overflow case to the `ParseValue` numeric tests once Driver.S7.Cli-001 is resolved so
the test asserts the wrapped `CommandException`.

**Resolution:** Resolved 2026-05-23 — added `S7CommandBaseBuildOptionsTests` covering Probe.Enabled=false (one-shot CLI guarantee), TimeoutMs→Timeout TimeSpan mapping, host/port/CPU/rack/slot flowing through, and tag-list passthrough. The overflow-edge `ParseValue` test was already added under Driver.S7.Cli-001 (`ParseValue_overflow_for_numeric_types_throws_CommandException`).

### Driver.S7.Cli-007

| Field | Value |
|---|---|
| Severity | Low |
| Category | Documentation & comments |
| Location | `src/Drivers/Cli/ZB.MOM.WW.OtOpcUa.Driver.S7.Cli/Commands/SubscribeCommand.cs:45-51` |
| Status | Resolved |

**Description:** The Modbus CLI `SubscribeCommand` carries an explanatory comment on
the `OnDataChange` handler ("Route every data-change event to the CliFx console (not
System.Console — the analyzer flags it + IConsole is the testable abstraction)"). The S7
`SubscribeCommand` is a near-verbatim copy but dropped that comment, so the non-obvious
reason the handler uses `console.Output.WriteLine` (synchronous, on a driver background
thread) instead of `System.Console` or the `async` `WriteLineAsync` is undocumented here.
Minor, but the rationale is worth keeping consistent across the CLI family.

**Recommendation:** Re-add the one-line comment from the Modbus `SubscribeCommand` so
the S7 copy explains why the event handler writes via `console.Output` synchronously.

**Resolution:** Resolved 2026-05-23 — re-added the explanatory comment above the `OnDataChange` handler in the S7 `SubscribeCommand`, mirroring the Modbus copy: it explains the use of the CliFx `IConsole.Output` abstraction (rather than `System.Console`) and notes that the handler runs synchronously because it's raised from a driver background thread. Added `SubscribeCommandConsoleHandlerCommentTests` to guard the rationale against future copy-paste regressions.

## Re-review 2026-06-19 (commit 111d6983)

#### Checklist coverage

| # | Category | Result |
|---|---|---|
| 1 | Correctness & logic bugs | Driver.S7.Cli-009, Driver.S7.Cli-010 |
| 2 | OtOpcUa conventions | No issues found |
| 3 | Concurrency & thread safety | Driver.S7.Cli-011 |
| 4 | Error handling & resilience | Driver.S7.Cli-008, Driver.S7.Cli-009 |
| 5 | Security | No issues found |
| 6 | Performance & resource management | No issues found |
| 7 | Design-document adherence | Driver.S7.Cli-012 |
| 8 | Code organization & conventions | No issues found |
| 9 | Testing coverage | No issues found |
| 10 | Documentation & comments | No issues found |

### Driver.S7.Cli-008

| Field | Value |
|---|---|
| Severity | Medium |
| Category | Error handling & resilience |
| Location | `src/Drivers/Cli/ZB.MOM.WW.OtOpcUa.Driver.S7.Cli/S7CommandBase.cs` |
| Status | Resolved |

**Description:** `S7CommandBase` had no `ValidateEndpoint()` method, so an operator passing `--port 0`, `--port 65536`, or `--timeout-ms -1` got an opaque socket or argument exception thrown deep inside the S7.Net library stack rather than a clean `CommandException`. The analogous `ModbusCommandBase.ValidateEndpoint()` already had this guard.

**Recommendation:** Add a `ValidateEndpoint()` method to `S7CommandBase` that validates `Port` (1..65535) and `TimeoutMs` (strictly positive) before the driver is created, and call it at the top of each command's `ExecuteAsync`. Add tests covering invalid port values, non-positive timeout, boundary port values (1 and 65535), and the happy path.

**Resolution:** Resolved 2026-06-19 — added `ValidateEndpoint()` to `S7CommandBase` (port 1..65535, TimeoutMs strictly positive, both throw `CommandException` with the flag name); called it in `ProbeCommand`, `ReadCommand`, `WriteCommand`, and `SubscribeCommand`; added `S7EndpointValidationTests` (8 cases).

### Driver.S7.Cli-009

| Field | Value |
|---|---|
| Severity | Low |
| Category | Correctness & logic bugs |
| Location | `src/Drivers/Cli/ZB.MOM.WW.OtOpcUa.Driver.S7.Cli/Commands/ProbeCommand.cs:61`, `Commands/ReadCommand.cs`, `Commands/WriteCommand.cs` |
| Status | Resolved |

**Description:** Two related cancellation-handling gaps: (1) `ProbeCommand` caught `OperationCanceledException` without the `when (ct.IsCancellationRequested)` filter — a driver-internal timeout raised with a different CancellationToken would be caught and re-thrown from the wrong handler branch, masking the real error. (2) `ReadCommand` and `WriteCommand` had no `OperationCanceledException` handler at all — when the operator presses Ctrl+C during a connect or read, CliFx handles the exception silently, but the Modbus/AbCip CLI pattern of printing "Cancelled." and letting the caller see a clean one-line acknowledgement was missing.

**Recommendation:** (1) Add `when (ct.IsCancellationRequested)` to the `ProbeCommand` catch. (2) Wrap `ReadCommand` and `WriteCommand`'s driver calls in a `try/catch (OperationCanceledException) when (ct.IsCancellationRequested)` that prints "Cancelled." and returns, matching the Modbus pattern. Add source-level tests to guard the patterns.

**Resolution:** Resolved 2026-06-19 — added `when (ct.IsCancellationRequested)` filter to `ProbeCommand`; wrapped `ReadCommand` and `WriteCommand` driver calls in `try/catch (OperationCanceledException) when (ct.IsCancellationRequested)` printing "Cancelled."; added `CancellationHandlingTests` (3 cases).

### Driver.S7.Cli-010

| Field | Value |
|---|---|
| Severity | Low |
| Category | Code organization & conventions |
| Location | `src/Drivers/Cli/ZB.MOM.WW.OtOpcUa.Driver.S7.Cli/Commands/SubscribeCommand.cs` |
| Status | Resolved |

**Description:** `SubscribeCommand.ExecuteAsync` did not call `FlushLogging()` in its `finally` block. `DriverCommandBase.ConfigureLogging()` documents that `FlushLogging()` must be called in a `finally` to prevent buffered Serilog output from being discarded on process exit. For the long-running `subscribe` command (which can run for minutes before Ctrl+C), reconnect warnings and other log lines emitted just before termination would be silently lost. The `AbCip` CLI subscribe command already had this call.

**Recommendation:** Add `FlushLogging()` as the last statement in `SubscribeCommand`'s outer `finally` block. Guard with a source-level test to prevent regression.

**Resolution:** Resolved 2026-06-19 — added `FlushLogging()` as the final statement in `SubscribeCommand`'s `finally` block; added `FlushLoggingConventionTests` (1 case).

### Driver.S7.Cli-011

| Field | Value |
|---|---|
| Severity | Low |
| Category | Concurrency & thread safety |
| Location | `src/Drivers/Cli/ZB.MOM.WW.OtOpcUa.Driver.S7.Cli/Commands/SubscribeCommand.cs:61-67` |
| Status | Resolved |

**Description:** The `OnDataChange` handler in `SubscribeCommand` called `console.Output.WriteLine` without serialisation. `OnDataChange` is raised on the `PollGroupEngine` background timer thread; if two poll ticks complete close together (e.g. with a very short `--interval-ms`), concurrent handler invocations can interleave partial lines on the output. The Modbus CLI subscribe command guards against this with a `lock(writeLock)` wrapper; the S7 command was a copy-paste that dropped the lock.

**Recommendation:** Add a `var writeLock = new object();` beside the driver declaration and wrap the `console.Output.WriteLine` call in `lock (writeLock)`. Guard with a source-level test.

**Resolution:** Resolved 2026-06-19 — added `var writeLock = new object()` and wrapped `console.Output.WriteLine` in `lock (writeLock)` inside `SubscribeCommand.OnDataChange`; added `SubscribeCommandWriteLockTests` (1 case).

### Driver.S7.Cli-012

| Field | Value |
|---|---|
| Severity | Low |
| Category | Design-document adherence |
| Location | `src/Drivers/Cli/ZB.MOM.WW.OtOpcUa.Driver.S7.Cli/Commands/ProbeCommand.cs` |
| Status | Deferred |

**Description:** The Modbus CLI `ProbeCommand` was enhanced (Driver.Modbus.Cli-006) with a `ComputeVerdict` method that derives a single `OK / DEGRADED / FAIL` headline from both the driver state and the probe-read snapshot StatusCode. This prevents the contradictory output `Health: Healthy` when the probe snapshot carries a Bad quality code. The S7 `ProbeCommand` has the same contradiction risk — it prints `Health: {health.State}` without cross-checking the snapshot quality.

**Recommendation:** Port the `ComputeVerdict` logic from the Modbus CLI to the S7 CLI: derive a verdict from `health.State` + `snapshot[0].StatusCode` and print it as the headline `Verdict:` line, keeping `Health:` for the raw driver state.

**Resolution:** Deferred — enhancement rather than bug; requires mirroring the Modbus CLI's `ComputeVerdict` pattern which is a design change not a correctness fix. No test regression risk from deferring.
