Skip to main content

bhtune_cli/
args.rs

1//! Command-line argument definitions (`clap` derive) and the small wrapper enums that adapt
2//! `bhtune-core`'s domain enums to `clap::ValueEnum`.
3//!
4//! Rust's orphan rule forbids implementing a foreign trait (`clap::ValueEnum`) for a foreign
5//! type (`bhtune_core::ProcessType` etc.), so each domain enum this CLI exposes as a flag
6//! gets a small local wrapper here with a `From`/`Into` conversion — not a design choice, a
7//! language requirement.
8
9use std::path::PathBuf;
10
11use bhtune_core::TagOverrides;
12use bhtune_runtime::tune::{
13    DEFAULT_SIM_DEAD_TIME, DEFAULT_SIM_GAIN, DEFAULT_SIM_INITIAL_VALUE, DEFAULT_SIM_NOISE,
14    DEFAULT_SIM_SEED, DEFAULT_SIM_TAU, DriverKind, TuneRequest, TuneRequestValidationError,
15    ValidatedTuneRequest, validate_finite_f32, validate_positive_u32,
16};
17use clap::{Args, Parser, Subcommand, ValueEnum};
18
19/// `value_parser` for every `f32` CLI flag that can reach `bhtune-core` unvalidated. A
20/// driver tag read is checked for finiteness by the runtime, but a CLI flag value bypasses
21/// that check entirely (see the runtime tag builder's `TagOrValue::Value` path) --
22/// without this, `--relay-amp nan` or `--sim-gain inf` would flow straight into the tuning
23/// math. See AGENTS.md's "Live-plant safety hardening" section.
24fn finite_f32(s: &str) -> Result<f32, String> {
25    let value: f32 = s
26        .parse()
27        .map_err(|_| format!("'{s}' is not a valid number"))?;
28    validate_finite_f32("value", value)
29        .map_err(|_| format!("'{s}' must be a finite number (not NaN or infinite)"))?;
30    Ok(value)
31}
32
33/// `value_parser` for a `u32` CLI flag where `0` parses fine but is nonsensical for the
34/// flag's unit. `--cycles-count 0` is the motivating case: it used to reach
35/// `bhtune-core::measure_oscillation`'s internal `assert!` and panic mid-run, after the loop
36/// had already been switched to manual and stroked.
37fn positive_u32(s: &str) -> Result<u32, String> {
38    let value: u32 = s
39        .parse()
40        .map_err(|_| format!("'{s}' is not a valid non-negative integer"))?;
41    validate_positive_u32("value", value).map_err(|_| "must be at least 1".to_string())?;
42    Ok(value)
43}
44
45/// The `bhtune` CLI: a scriptable, no-GUI way to run an MRFT tune and inspect its history.
46#[derive(Parser, Debug)]
47#[command(name = "bhtune", version, about = "Headless MRFT auto-tuner")]
48pub struct Cli {
49    /// Path to a TOML config file (default: platform-specific, see `crate::config`).
50    #[arg(long, global = true, value_name = "PATH")]
51    pub config: Option<PathBuf>,
52
53    /// Path to the SQLite database file (default: a platform-standard data directory, see
54    /// `crate::config::default_db_path_from`). CLI > `BHTUNE_DB` env var > `db` in the
55    /// config file > platform default -- see `crate::config::resolve_db_path`.
56    #[arg(long, global = true, env = "BHTUNE_DB", value_name = "PATH")]
57    pub db: Option<PathBuf>,
58
59    /// Path to a user-supplied DCS/PLC template catalog, auto-loaded on every startup in
60    /// addition to the built-in templates (default: platform-specific, next to the config
61    /// file -- see `crate::config::templates_path_from`). A missing file at the default
62    /// location is fine; a file that fails to parse or validate is a hard error. CLI >
63    /// `BHTUNE_TEMPLATES` env var > `templates` in the config file > platform default --
64    /// see `crate::config::load_user_templates`.
65    #[arg(long, global = true, env = "BHTUNE_TEMPLATES", value_name = "PATH")]
66    pub templates: Option<PathBuf>,
67
68    /// Delete tune runs (and their samples/results/write-back audit rows) older than this
69    /// many days, automatically, on every startup (default: unset -- retain forever). CLI >
70    /// `BHTUNE_RETENTION_DAYS` env var > `retention_days` in the config file > (no default)
71    /// -- see `crate::config::resolve_retention_days`. `bhtune history prune` applies the
72    /// same policy on demand, with a `--dry-run` preview, instead of waiting for the next
73    /// startup.
74    #[arg(long, global = true, env = "BHTUNE_RETENTION_DAYS", value_parser = positive_u32)]
75    pub retention_days: Option<u32>,
76
77    /// Log level / directive spec, e.g. "info" or
78    /// "bhtune_cli=debug,bhtune_runtime=debug,sqlx=warn" (default: info). Diagnostic detail
79    /// only -- never printed to stdout, so it can never interleave with `--output json`'s
80    /// single-object contract; see `crate::logging`.
81    #[arg(long, global = true, env = "RUST_LOG")]
82    pub log_level: Option<String>,
83
84    /// Directory to write log files to (default: a platform-standard data directory, see
85    /// `crate::config::default_log_dir_from`).
86    #[arg(long, global = true, value_name = "PATH")]
87    pub log_dir: Option<PathBuf>,
88
89    /// Log file format: "pretty" or "json" (default: pretty).
90    #[arg(long, global = true)]
91    pub log_format: Option<String>,
92
93    /// Log file rotation: "hourly", "daily", or "never" (default: daily).
94    #[arg(long, global = true)]
95    pub log_rotation: Option<String>,
96
97    #[command(subcommand)]
98    pub command: Command,
99}
100
101#[derive(Subcommand, Debug)]
102#[allow(clippy::large_enum_variant)]
103pub enum Command {
104    /// Validate tune inputs and live readings without starting a tune or writing to the loop.
105    Check(CheckArgs),
106    /// Run an MRFT tune against a real OPC DA loop or the in-process simulator.
107    Tune(TuneArgs),
108    /// Run a zero-configuration demo MRFT tune against the built-in FOPDT simulator.
109    Simulate(SimulateArgs),
110    /// Inspect and manage DCS/PLC templates.
111    Template {
112        #[command(subcommand)]
113        command: TemplateCommand,
114    },
115    /// Inspect past tune runs.
116    History {
117        #[command(subcommand)]
118        command: HistoryCommand,
119    },
120    /// Restore the recorded pre-tune loop values after an explicitly recoverable orphan.
121    RestoreLoop(RestoreLoopArgs),
122    /// Export one run's recorded samples as CSV or JSON.
123    Export(ExportArgs),
124    /// Low-level OPC DA passthrough (diagnostics) via the opcda-bridge gateway, bypassing
125    /// the tuning engine entirely.
126    Opc {
127        /// How to print OPC diagnostic results.
128        #[arg(long, global = true, value_enum, default_value = "table")]
129        output: crate::output::OutputFormat,
130        #[command(subcommand)]
131        command: OpcCommand,
132    },
133}
134
135impl Command {
136    /// The `--output` format this command asked for, or [`crate::output::OutputFormat::Table`]
137    /// for commands that don't have the concept yet (`Template`/`Export`/`Opc` -- `Export`
138    /// already has its own unrelated `--output <path>` flag naming the destination file).
139    /// Read before the command is dispatched (and potentially moved), so a config/database
140    /// error occurring before dispatch can still be reported in the format the caller
141    /// actually asked for -- see `lib.rs::run_with_cli`.
142    pub(crate) fn output_format(&self) -> crate::output::OutputFormat {
143        match self {
144            Command::Check(args) => args.tune.output,
145            Command::Tune(args) => args.output,
146            Command::Simulate(args) => args.output,
147            Command::History { command } => command.output_format(),
148            Command::RestoreLoop(args) => args.output,
149            Command::Template { .. } | Command::Export(_) => crate::output::OutputFormat::Table,
150            Command::Opc { output, .. } => *output,
151        }
152    }
153}
154
155#[derive(Args, Debug, Clone, PartialEq, Eq)]
156pub struct RestoreLoopArgs {
157    /// ID of the failed run marked explicitly eligible by the Full-mode orphan sweep.
158    pub run_id: i64,
159    /// Cross-check the run's recorded OPC bridge host; never selects a different gateway.
160    #[arg(long)]
161    pub bridge_host: Option<String>,
162    /// Cross-check the run's recorded OPC server; never selects a different controller.
163    #[arg(long)]
164    pub server: Option<String>,
165    /// Confirm the live restore. Required; no interactive prompt is offered.
166    #[arg(long)]
167    pub yes: bool,
168    /// How to print the per-step restore report.
169    #[arg(long, value_enum, default_value = "table")]
170    pub output: crate::output::OutputFormat,
171}
172
173/// A [`bhtune_core::ProcessType`] value, as a CLI flag.
174#[derive(ValueEnum, Debug, Clone, Copy, PartialEq, Eq)]
175pub enum ProcessTypeArg {
176    Flow,
177    PressureLine,
178    PressureVessel,
179    Level,
180    TemperatureMixing,
181    TemperatureHeatExchange,
182}
183
184impl From<ProcessTypeArg> for bhtune_core::ProcessType {
185    fn from(value: ProcessTypeArg) -> Self {
186        match value {
187            ProcessTypeArg::Flow => bhtune_core::ProcessType::Flow,
188            ProcessTypeArg::PressureLine => bhtune_core::ProcessType::PressureLine,
189            ProcessTypeArg::PressureVessel => bhtune_core::ProcessType::PressureVessel,
190            ProcessTypeArg::Level => bhtune_core::ProcessType::Level,
191            ProcessTypeArg::TemperatureMixing => bhtune_core::ProcessType::TemperatureMixing,
192            ProcessTypeArg::TemperatureHeatExchange => {
193                bhtune_core::ProcessType::TemperatureHeatExchange
194            }
195        }
196    }
197}
198
199/// The reverse of the `impl From<ProcessTypeArg>` above -- needed by `bhtune-server`'s
200/// `POST /api/runs`, whose request body deserializes straight into `bhtune-core`'s domain
201/// enums (already `Deserialize`/`ToSchema` via existing feature-gating, and meaningful
202/// outside a CLI context) rather than these CLI-only `clap::ValueEnum` wrappers, then
203/// converts into a [`TuneArgs`] to reuse this crate's tune orchestration unchanged.
204impl From<bhtune_core::ProcessType> for ProcessTypeArg {
205    fn from(value: bhtune_core::ProcessType) -> Self {
206        match value {
207            bhtune_core::ProcessType::Flow => ProcessTypeArg::Flow,
208            bhtune_core::ProcessType::PressureLine => ProcessTypeArg::PressureLine,
209            bhtune_core::ProcessType::PressureVessel => ProcessTypeArg::PressureVessel,
210            bhtune_core::ProcessType::Level => ProcessTypeArg::Level,
211            bhtune_core::ProcessType::TemperatureMixing => ProcessTypeArg::TemperatureMixing,
212            bhtune_core::ProcessType::TemperatureHeatExchange => {
213                ProcessTypeArg::TemperatureHeatExchange
214            }
215        }
216    }
217}
218
219/// A [`bhtune_core::ControllerType`] value, as a CLI flag.
220#[derive(ValueEnum, Debug, Clone, Copy, PartialEq, Eq)]
221pub enum ControllerTypeArg {
222    P,
223    Pi,
224    Pid,
225}
226
227impl From<ControllerTypeArg> for bhtune_core::ControllerType {
228    fn from(value: ControllerTypeArg) -> Self {
229        match value {
230            ControllerTypeArg::P => bhtune_core::ControllerType::P,
231            ControllerTypeArg::Pi => bhtune_core::ControllerType::Pi,
232            ControllerTypeArg::Pid => bhtune_core::ControllerType::Pid,
233        }
234    }
235}
236
237/// The reverse of the `impl From<ControllerTypeArg>` above -- see
238/// `impl From<bhtune_core::ProcessType> for ProcessTypeArg`'s doc comment for why.
239impl From<bhtune_core::ControllerType> for ControllerTypeArg {
240    fn from(value: bhtune_core::ControllerType) -> Self {
241        match value {
242            bhtune_core::ControllerType::P => ControllerTypeArg::P,
243            bhtune_core::ControllerType::Pi => ControllerTypeArg::Pi,
244            bhtune_core::ControllerType::Pid => ControllerTypeArg::Pid,
245        }
246    }
247}
248
249/// A [`bhtune_core::ControllerDirection`] value, as a CLI flag.
250#[derive(ValueEnum, Debug, Clone, Copy, PartialEq, Eq)]
251pub enum DirectionArg {
252    Direct,
253    Reverse,
254}
255
256impl From<DirectionArg> for bhtune_core::ControllerDirection {
257    fn from(value: DirectionArg) -> Self {
258        match value {
259            DirectionArg::Direct => bhtune_core::ControllerDirection::Direct,
260            DirectionArg::Reverse => bhtune_core::ControllerDirection::Reverse,
261        }
262    }
263}
264
265/// The reverse of the `impl From<DirectionArg>` above -- see
266/// `impl From<bhtune_core::ProcessType> for ProcessTypeArg`'s doc comment for why.
267impl From<bhtune_core::ControllerDirection> for DirectionArg {
268    fn from(value: bhtune_core::ControllerDirection) -> Self {
269        match value {
270            bhtune_core::ControllerDirection::Direct => DirectionArg::Direct,
271            bhtune_core::ControllerDirection::Reverse => DirectionArg::Reverse,
272        }
273    }
274}
275
276/// Which [`bhtune_driver::Driver`] implementation a `tune` run should use.
277#[derive(ValueEnum, Debug, Clone, Copy, PartialEq, Eq)]
278pub enum DriverKindArg {
279    /// A real OPC DA server, reached through an opcda-bridge gateway.
280    Opcda,
281    /// The in-process FOPDT simulator — no external dependency at all.
282    Simulator,
283}
284
285/// The reverse of `DriverKindArg -> TuneDriver` conversions elsewhere in this crate, for
286/// `bhtune-server`'s `POST /api/runs`, whose request body accepts
287/// [`bhtune_db::models::TuneDriver`] directly (already `Deserialize`/`ToSchema`, and the one
288/// enum every other run-history route already uses on the wire -- see `routes/history.rs` in
289/// `bhtune-server`) rather than this CLI-only wrapper.
290///
291/// `TryFrom`, not `From`: [`bhtune_db::models::TuneDriver::Replay`] has no [`DriverKindArg`]
292/// counterpart at all yet (`driver-replay` in AGENTS.md is still unimplemented, so there is
293/// no `crate::driver::build` case that could ever construct one), so a request naming it
294/// must be rejected explicitly rather than silently mapped to something else.
295impl TryFrom<bhtune_db::models::TuneDriver> for DriverKindArg {
296    type Error = ReplayDriverUnsupported;
297
298    fn try_from(value: bhtune_db::models::TuneDriver) -> Result<Self, Self::Error> {
299        match value {
300            bhtune_db::models::TuneDriver::Opcda => Ok(DriverKindArg::Opcda),
301            bhtune_db::models::TuneDriver::Simulator => Ok(DriverKindArg::Simulator),
302            bhtune_db::models::TuneDriver::Replay => Err(ReplayDriverUnsupported),
303        }
304    }
305}
306
307/// The error [`DriverKindArg::try_from`] returns for
308/// [`bhtune_db::models::TuneDriver::Replay`] -- see that impl's doc comment.
309#[derive(Debug, Clone, Copy, PartialEq, Eq)]
310pub struct ReplayDriverUnsupported;
311
312impl std::fmt::Display for ReplayDriverUnsupported {
313    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
314        write!(
315            f,
316            "the replay driver cannot be used to start a new tune run (it exists only for \
317             offline golden-trace validation, not live/simulated tuning)"
318        )
319    }
320}
321
322impl std::error::Error for ReplayDriverUnsupported {}
323
324/// A [`bhtune_core::ResponseLevel`] value, as a CLI flag (`--write-pid
325/// <aggressive|moderate|sluggish>`).
326#[derive(ValueEnum, Debug, Clone, Copy, PartialEq, Eq)]
327pub enum ResponseLevelArg {
328    Aggressive,
329    Moderate,
330    Sluggish,
331}
332
333impl From<ResponseLevelArg> for bhtune_core::ResponseLevel {
334    fn from(value: ResponseLevelArg) -> Self {
335        match value {
336            ResponseLevelArg::Aggressive => bhtune_core::ResponseLevel::Aggressive,
337            ResponseLevelArg::Moderate => bhtune_core::ResponseLevel::Moderate,
338            ResponseLevelArg::Sluggish => bhtune_core::ResponseLevel::Sluggish,
339        }
340    }
341}
342
343/// The reverse of the `impl From<ResponseLevelArg>` above -- see
344/// `impl From<bhtune_core::ProcessType> for ProcessTypeArg`'s doc comment for why.
345impl From<bhtune_core::ResponseLevel> for ResponseLevelArg {
346    fn from(value: bhtune_core::ResponseLevel) -> Self {
347        match value {
348            bhtune_core::ResponseLevel::Aggressive => ResponseLevelArg::Aggressive,
349            bhtune_core::ResponseLevel::Moderate => ResponseLevelArg::Moderate,
350            bhtune_core::ResponseLevel::Sluggish => ResponseLevelArg::Sluggish,
351        }
352    }
353}
354
355/// Flags shared by `tune`, `check`, and (a defaulted subset of) `simulate`.
356#[derive(Args, Debug, Clone)]
357pub struct TuneArgs {
358    /// PV tag prefix; the rest of the tag set is derived from it using `--template`'s
359    /// suffix convention. Ignored for `--driver simulator`, which uses two fixed internal
360    /// tag names instead.
361    #[arg(short = 't', long)]
362    pub tagname: String,
363
364    /// DCS/PLC template name (see `bhtune template list`).
365    #[arg(long)]
366    pub template: String,
367
368    #[arg(long, value_enum)]
369    pub process_type: ProcessTypeArg,
370
371    #[arg(long, value_enum)]
372    pub controller_type: ControllerTypeArg,
373
374    /// Relay amplitude, as a percentage of the MV range.
375    #[arg(long, value_parser = finite_f32)]
376    pub relay_amp: f32,
377
378    /// Relay cycles to skip before counting begins (default: looked up per `--process-type`).
379    #[arg(long)]
380    pub cycles_skip: Option<u32>,
381
382    /// Relay cycles to count once the skip period ends (default: looked up per
383    /// `--process-type`).
384    #[arg(long, value_parser = positive_u32)]
385    pub cycles_count: Option<u32>,
386
387    /// Seconds a switch must persist before it's accepted (default: looked up per
388    /// `--process-type`).
389    #[arg(long)]
390    pub noise_protection_secs: Option<u32>,
391
392    /// Test-only override used to keep unit fixtures fast without exposing a supported CLI
393    /// timing flag.
394    #[cfg(test)]
395    #[arg(skip)]
396    pub mrft_delay: u32,
397
398    /// Which driver backs this tune or preflight check.
399    #[arg(long, value_enum)]
400    pub driver: DriverKindArg,
401
402    /// opcda-bridge gateway address. bhtune connects to the bridge gateway rather than a
403    /// DCOM host directly — see AGENTS.md's OPC DA integration notes. Only meaningful with
404    /// `--driver opcda` (default: `crate::config::DEFAULT_BRIDGE_HOST`, overridable via the
405    /// `BHTUNE_BRIDGE_HOST` env var or the config file's `bridge_host` key).
406    #[arg(long, env = "BHTUNE_BRIDGE_HOST")]
407    pub bridge_host: Option<String>,
408
409    /// OPC DA server ProgID (legacy: `-s`/`--opcServerID`). Required with `--driver opcda`.
410    #[arg(long)]
411    pub server: Option<String>,
412
413    /// Simulator process gain (`--driver simulator` only).
414    #[arg(long, default_value_t = DEFAULT_SIM_GAIN, value_parser = finite_f32)]
415    pub sim_gain: f32,
416    /// Simulator process time constant, in seconds (`--driver simulator` only).
417    #[arg(long, default_value_t = DEFAULT_SIM_TAU, value_parser = finite_f32)]
418    pub sim_tau: f32,
419    /// Simulator dead time, in seconds (`--driver simulator` only).
420    #[arg(long, default_value_t = DEFAULT_SIM_DEAD_TIME, value_parser = finite_f32)]
421    pub sim_dead_time: f32,
422    /// Simulator measurement noise amplitude (`--driver simulator` only).
423    #[arg(long, default_value_t = DEFAULT_SIM_NOISE, value_parser = finite_f32)]
424    pub sim_noise: f32,
425    /// Simulator RNG seed, for reproducible noise (`--driver simulator` only).
426    #[arg(long, default_value_t = DEFAULT_SIM_SEED)]
427    pub sim_seed: u64,
428    /// Simulator initial PV (`--driver simulator` only).
429    #[arg(long, default_value_t = DEFAULT_SIM_INITIAL_VALUE, value_parser = finite_f32)]
430    pub sim_initial_pv: f32,
431    /// Simulator initial MV (`--driver simulator` only).
432    #[arg(long, default_value_t = DEFAULT_SIM_INITIAL_VALUE, value_parser = finite_f32)]
433    pub sim_initial_mv: f32,
434
435    /// Fixed PV range high, overriding a live tag read (legacy: the PV range "toggle
436    /// tag/value" button). Required (defaults to 100.0) for `--driver simulator`, which has
437    /// no range tags at all.
438    #[arg(long, value_parser = finite_f32)]
439    pub pv_range_high: Option<f32>,
440    /// Fixed PV range low, overriding a live tag read.
441    #[arg(long, value_parser = finite_f32)]
442    pub pv_range_low: Option<f32>,
443    /// Fixed MV range high, overriding a live tag read.
444    #[arg(long, value_parser = finite_f32)]
445    pub mv_range_high: Option<f32>,
446    /// Fixed MV range low, overriding a live tag read.
447    #[arg(long, value_parser = finite_f32)]
448    pub mv_range_low: Option<f32>,
449    /// Fixed controller direction, overriding a live tag read.
450    #[arg(long, value_enum)]
451    pub direction: Option<DirectionArg>,
452
453    /// Per-tune replacements for template-derived tag names. This is populated by the HTTP
454    /// API/UI; the CLI has no separate flags for the nested object.
455    #[arg(skip)]
456    pub tag_overrides: Option<TagOverrides>,
457
458    /// Test-only override used to keep unit fixtures fast without exposing a supported CLI
459    /// timing flag.
460    #[cfg(test)]
461    #[arg(skip)]
462    pub poll_interval_ms: u64,
463
464    /// Test-only override used to keep unit fixtures bounded without exposing a supported CLI
465    /// timing flag.
466    #[cfg(test)]
467    #[arg(skip)]
468    pub timeout_secs: u64,
469
470    /// Operator notes to attach to a tune run. Ignored by `check`, which does not create a run.
471    #[arg(long)]
472    pub notes: Option<String>,
473
474    /// Confirm an unattended PID write-back. Required alongside `--write-pid` for `tune` and
475    /// `simulate`; `check` only assesses readiness and does not require this flag. Has no
476    /// effect without `--write-pid`.
477    #[arg(long)]
478    pub yes: bool,
479
480    /// For `tune`, write this response level's calculated PID parameters back to the DCS
481    /// without prompting; requires `--yes`. For `check`, assess read-only write-back readiness
482    /// only; no `--yes` is required and no write occurs.
483    #[arg(long, value_enum)]
484    pub write_pid: Option<ResponseLevelArg>,
485
486    /// Test-only override used to exercise operation timeouts without exposing a supported CLI
487    /// timing flag.
488    #[cfg(test)]
489    #[arg(skip)]
490    pub op_timeout_secs: u64,
491
492    /// Test-only override used to exercise restore timeouts without exposing a supported CLI
493    /// timing flag.
494    #[cfg(test)]
495    #[arg(skip)]
496    pub restore_timeout_secs: u64,
497
498    /// How to print this command's result.
499    #[arg(long, value_enum, default_value = "table")]
500    pub output: crate::output::OutputFormat,
501}
502
503/// Inputs shared by `bhtune tune` and the read-only `bhtune check` command.
504#[derive(Args, Debug, Clone)]
505pub struct CheckArgs {
506    #[command(flatten)]
507    pub tune: TuneArgs,
508
509    /// Treat warnings as failed checks.
510    #[arg(long)]
511    pub strict: bool,
512}
513
514impl TryFrom<TuneArgs> for ValidatedTuneRequest {
515    type Error = TuneRequestValidationError;
516
517    fn try_from(args: TuneArgs) -> Result<Self, Self::Error> {
518        let request = TuneRequest {
519            tagname: args.tagname,
520            template: args.template,
521            process_type: args.process_type.into(),
522            controller_type: args.controller_type.into(),
523            relay_amp: args.relay_amp,
524            cycles_skip: args.cycles_skip,
525            cycles_count: args.cycles_count,
526            noise_protection_secs: args.noise_protection_secs,
527            driver: match args.driver {
528                DriverKindArg::Opcda => DriverKind::Opcda,
529                DriverKindArg::Simulator => DriverKind::Simulator,
530            },
531            bridge_host: args.bridge_host,
532            server: args.server,
533            sim_gain: args.sim_gain,
534            sim_tau: args.sim_tau,
535            sim_dead_time: args.sim_dead_time,
536            sim_noise: args.sim_noise,
537            sim_seed: args.sim_seed,
538            sim_initial_pv: args.sim_initial_pv,
539            sim_initial_mv: args.sim_initial_mv,
540            pv_range_high: args.pv_range_high,
541            pv_range_low: args.pv_range_low,
542            mv_range_high: args.mv_range_high,
543            mv_range_low: args.mv_range_low,
544            direction: args.direction.map(Into::into),
545            tag_overrides: args.tag_overrides,
546            notes: args.notes,
547            yes: args.yes,
548            write_pid: args.write_pid.map(Into::into),
549        };
550        ValidatedTuneRequest::try_from(request)
551    }
552}
553
554/// `bhtune simulate`: every field defaulted for a true zero-configuration demo run.
555#[derive(Parser, Debug, Clone)]
556pub struct SimulateArgs {
557    #[arg(short = 't', long, default_value = "Sim.Loop1.PV")]
558    pub tagname: String,
559
560    #[arg(long, default_value = "Yokogawa CentumVP")]
561    pub template: String,
562
563    #[arg(long, value_enum, default_value = "flow")]
564    pub process_type: ProcessTypeArg,
565
566    #[arg(long, value_enum, default_value = "pi")]
567    pub controller_type: ControllerTypeArg,
568
569    #[arg(long, default_value_t = 10.0, value_parser = finite_f32)]
570    pub relay_amp: f32,
571
572    #[arg(long)]
573    pub cycles_skip: Option<u32>,
574    #[arg(long, value_parser = positive_u32)]
575    pub cycles_count: Option<u32>,
576    #[arg(long)]
577    pub noise_protection_secs: Option<u32>,
578    #[cfg(test)]
579    #[arg(skip)]
580    pub mrft_delay: u32,
581
582    #[arg(long, default_value_t = DEFAULT_SIM_GAIN, value_parser = finite_f32)]
583    pub sim_gain: f32,
584    #[arg(long, default_value_t = DEFAULT_SIM_TAU, value_parser = finite_f32)]
585    pub sim_tau: f32,
586    #[arg(long, default_value_t = DEFAULT_SIM_DEAD_TIME, value_parser = finite_f32)]
587    pub sim_dead_time: f32,
588    #[arg(long, default_value_t = DEFAULT_SIM_NOISE, value_parser = finite_f32)]
589    pub sim_noise: f32,
590    #[arg(long, default_value_t = DEFAULT_SIM_SEED)]
591    pub sim_seed: u64,
592    #[arg(long, default_value_t = DEFAULT_SIM_INITIAL_VALUE, value_parser = finite_f32)]
593    pub sim_initial_pv: f32,
594    #[arg(long, default_value_t = DEFAULT_SIM_INITIAL_VALUE, value_parser = finite_f32)]
595    pub sim_initial_mv: f32,
596
597    #[cfg(test)]
598    #[arg(skip)]
599    pub poll_interval_ms: u64,
600
601    /// Test-only override used to keep unit fixtures bounded without exposing a supported CLI
602    /// timing flag.
603    #[cfg(test)]
604    #[arg(skip)]
605    pub timeout_secs: u64,
606
607    /// Operator notes to attach to this run. See [`TuneArgs::notes`].
608    #[arg(long)]
609    pub notes: Option<String>,
610
611    /// See `TuneArgs::yes`.
612    #[arg(long)]
613    pub yes: bool,
614
615    /// See `TuneArgs::write_pid`. Note the built-in FOPDT simulator has no PID constant
616    /// tags at all (see `build_loop_tags`), so write-back is always skipped for `simulate`
617    /// regardless of this flag -- it's accepted here purely so `simulate`'s flag surface
618    /// stays a strict defaulted subset of `tune`'s, matching every other field.
619    #[arg(long, value_enum)]
620    pub write_pid: Option<ResponseLevelArg>,
621
622    /// Test-only override used to exercise operation timeouts without exposing a supported CLI
623    /// timing flag.
624    #[cfg(test)]
625    #[arg(skip)]
626    pub op_timeout_secs: u64,
627
628    /// Test-only override used to exercise restore timeouts without exposing a supported CLI
629    /// timing flag.
630    #[cfg(test)]
631    #[arg(skip)]
632    pub restore_timeout_secs: u64,
633
634    /// See `TuneArgs::output`.
635    #[arg(long, value_enum, default_value = "table")]
636    pub output: crate::output::OutputFormat,
637}
638
639impl SimulateArgs {
640    /// Expands the defaulted `simulate` flags into a full [`TuneArgs`] with
641    /// `--driver simulator` implied, so `simulate` and `tune` share one execution path.
642    pub fn into_tune_args(self) -> TuneArgs {
643        TuneArgs {
644            tagname: self.tagname,
645            template: self.template,
646            process_type: self.process_type,
647            controller_type: self.controller_type,
648            relay_amp: self.relay_amp,
649            cycles_skip: self.cycles_skip,
650            cycles_count: self.cycles_count,
651            noise_protection_secs: self.noise_protection_secs,
652            driver: DriverKindArg::Simulator,
653            bridge_host: None,
654            server: None,
655            sim_gain: self.sim_gain,
656            sim_tau: self.sim_tau,
657            sim_dead_time: self.sim_dead_time,
658            sim_noise: self.sim_noise,
659            sim_seed: self.sim_seed,
660            sim_initial_pv: self.sim_initial_pv,
661            sim_initial_mv: self.sim_initial_mv,
662            // The simulator has no range/direction tags at all (see `driver-simulator`'s
663            // two-tag-only contract), so these must always be fixed values, defaulted to a
664            // plain 0-100% span and the direction already proven to produce a completing
665            // relay test in `bhtune-driver`'s own end-to-end test.
666            pv_range_high: Some(100.0),
667            pv_range_low: Some(0.0),
668            mv_range_high: Some(100.0),
669            mv_range_low: Some(0.0),
670            direction: Some(DirectionArg::Reverse),
671            tag_overrides: None,
672            notes: self.notes,
673            yes: self.yes,
674            write_pid: self.write_pid,
675            output: self.output,
676            #[cfg(test)]
677            mrft_delay: self.mrft_delay,
678            #[cfg(test)]
679            poll_interval_ms: self.poll_interval_ms,
680            #[cfg(test)]
681            timeout_secs: self.timeout_secs,
682            #[cfg(test)]
683            op_timeout_secs: self.op_timeout_secs,
684            #[cfg(test)]
685            restore_timeout_secs: self.restore_timeout_secs,
686        }
687    }
688}
689
690#[derive(Subcommand, Debug)]
691pub enum TemplateCommand {
692    /// List every template (built-in and user-imported).
693    List,
694    /// Show one template's full detail as JSON.
695    Show { name: String },
696    /// Import a template from a file. Accepts either a single template as JSON (see
697    /// `template export`'s default output shape) or a multi-template TOML catalog (the same
698    /// `[[template]]` array-of-tables shape as the embedded/user catalog, see `template
699    /// export --format toml`) -- the format is auto-detected from the file's content, not
700    /// its extension. A JSON single-template import is rejected outright if a template with
701    /// that name already exists; a TOML catalog import instead skips (and reports) any
702    /// template whose name already exists, so re-importing an updated community catalog
703    /// only adds what's new.
704    Import { path: PathBuf },
705    /// Export a template to a file, e.g. as a starting point for a site-specific copy or a
706    /// community catalog contribution.
707    Export {
708        name: String,
709        path: PathBuf,
710        /// File format to write. `toml` emits a single-entry `[[template]]` catalog block,
711        /// ready to paste into a catalog file or open as a contribution pull request.
712        #[arg(long, value_enum, default_value = "json")]
713        format: TemplateFileFormat,
714    },
715    /// Delete a template. Refuses if any saved loop still references it. A `Builtin`- or
716    /// `Catalog`-origin template reappears automatically the next time bhtune starts unless
717    /// it's also removed from its source (bhtune-core's embedded catalog for `Builtin`,
718    /// which only a new bhtune release can change; the user catalog file for `Catalog`).
719    Delete { name: String },
720}
721
722/// File format for `template export`/auto-detected on `template import`.
723#[derive(ValueEnum, Debug, Clone, Copy, PartialEq, Eq)]
724pub enum TemplateFileFormat {
725    Json,
726    Toml,
727}
728
729#[derive(Subcommand, Debug)]
730pub enum HistoryCommand {
731    /// List past runs, newest first.
732    List {
733        #[arg(long)]
734        outcome: Option<OutcomeArg>,
735        #[arg(long, default_value_t = 50)]
736        limit: i64,
737        #[arg(long, default_value_t = 0)]
738        offset: i64,
739        /// How to print the run list.
740        #[arg(long, value_enum, default_value = "table")]
741        output: crate::output::OutputFormat,
742    },
743    /// Show one run's full detail: config, initial readings, calculated results, and any
744    /// PID write-back audit rows.
745    Show {
746        run_id: i64,
747        /// How to print the run detail.
748        #[arg(long, value_enum, default_value = "table")]
749        output: crate::output::OutputFormat,
750    },
751    /// Undo a run's PID write-back, writing its recorded pre-write P/I/D values back to the
752    /// live loop. Reverts whichever `write`-kind write-back that run last recorded; refuses
753    /// if the run has none, if that write-back's pre-read itself failed (nothing to revert
754    /// to), or if the run did not use the `opcda` driver (nothing live to revert against).
755    Revert {
756        run_id: i64,
757        /// Cross-checked against the run's own recorded bridge host -- never used to resolve
758        /// a default, and deliberately has no `BHTUNE_BRIDGE_HOST`/config fallback the way
759        /// every other command's `--bridge-host` does, so an unrelated ambient env var can
760        /// never silently affect which gateway a revert targets. Omit this to use the
761        /// recorded value; a value that contradicts it is refused rather than preferred, so
762        /// a revert can never target a different gateway than the run it is undoing actually
763        /// used (`db-run-request-snapshot`).
764        #[arg(long)]
765        bridge_host: Option<String>,
766        /// Cross-checked against the run's own recorded OPC server -- never used to resolve
767        /// a default. Omit this to use the recorded value; a value that contradicts it is
768        /// refused rather than preferred, so a revert can never target a different server
769        /// than the run it is undoing actually used (`db-run-request-snapshot`).
770        #[arg(long)]
771        server: Option<String>,
772        /// Confirm writing to a live loop. Required -- there is no interactive prompt for
773        /// reverting, since there is no calculated result to choose between as there is for
774        /// `tune`'s own write-back step.
775        #[arg(long)]
776        yes: bool,
777        /// How to print the revert outcome.
778        #[arg(long, value_enum, default_value = "table")]
779        output: crate::output::OutputFormat,
780    },
781    /// Delete runs older than the configured retention policy (`history-retention`), without
782    /// waiting for the next automatic startup sweep.
783    Prune {
784        /// Delete runs older than this many days, overriding the configured `retention_days`
785        /// policy for this invocation only. Required if no retention policy is configured at
786        /// all (`--retention-days` / `BHTUNE_RETENTION_DAYS` / the config file's
787        /// `retention_days` key) -- there is no default "prune everything older than X" to
788        /// fall back to.
789        #[arg(long, value_parser = positive_u32)]
790        older_than_days: Option<u32>,
791        /// Report how many runs would be deleted, and as of what cutoff, without deleting
792        /// anything.
793        #[arg(long)]
794        dry_run: bool,
795        /// How to print the prune outcome.
796        #[arg(long, value_enum, default_value = "table")]
797        output: crate::output::OutputFormat,
798    },
799}
800
801impl HistoryCommand {
802    pub(crate) fn output_format(&self) -> crate::output::OutputFormat {
803        match self {
804            HistoryCommand::List { output, .. } => *output,
805            HistoryCommand::Show { output, .. } => *output,
806            HistoryCommand::Revert { output, .. } => *output,
807            HistoryCommand::Prune { output, .. } => *output,
808        }
809    }
810}
811
812/// A [`bhtune_db::models::TuneOutcome`] value, as a CLI flag.
813#[derive(ValueEnum, Debug, Clone, Copy, PartialEq, Eq)]
814pub enum OutcomeArg {
815    Running,
816    Completed,
817    Failed,
818    Aborted,
819}
820
821impl From<OutcomeArg> for bhtune_db::models::TuneOutcome {
822    fn from(value: OutcomeArg) -> Self {
823        match value {
824            OutcomeArg::Running => bhtune_db::models::TuneOutcome::Running,
825            OutcomeArg::Completed => bhtune_db::models::TuneOutcome::Completed,
826            OutcomeArg::Failed => bhtune_db::models::TuneOutcome::Failed,
827            OutcomeArg::Aborted => bhtune_db::models::TuneOutcome::Aborted,
828        }
829    }
830}
831
832#[derive(Parser, Debug)]
833pub struct ExportArgs {
834    pub run_id: i64,
835    #[arg(long, value_enum, default_value = "csv")]
836    pub format: ExportFormat,
837    /// Output file path (default: stdout).
838    #[arg(long)]
839    pub output: Option<PathBuf>,
840}
841
842#[derive(ValueEnum, Debug, Clone, Copy, PartialEq, Eq)]
843pub enum ExportFormat {
844    Csv,
845    Json,
846}
847
848#[derive(Subcommand, Debug)]
849pub enum OpcCommand {
850    /// Report gateway-wide version and protocol information without contacting an OPC server.
851    GatewayInfo {
852        /// (default: `crate::config::DEFAULT_BRIDGE_HOST`, overridable via `BHTUNE_BRIDGE_HOST`
853        /// or the config file's `bridge_host` key.)
854        #[arg(long, env = "BHTUNE_BRIDGE_HOST")]
855        bridge_host: Option<String>,
856    },
857    /// List the OPC DA servers registered on the bridge gateway's host.
858    Servers {
859        /// (default: `crate::config::DEFAULT_BRIDGE_HOST`, overridable via `BHTUNE_BRIDGE_HOST`
860        /// or the config file's `bridge_host` key.)
861        #[arg(long, env = "BHTUNE_BRIDGE_HOST")]
862        bridge_host: Option<String>,
863    },
864    /// Read one or more tags.
865    Read {
866        /// (default: `crate::config::DEFAULT_BRIDGE_HOST`, overridable via `BHTUNE_BRIDGE_HOST`
867        /// or the config file's `bridge_host` key.)
868        #[arg(long, env = "BHTUNE_BRIDGE_HOST")]
869        bridge_host: Option<String>,
870        /// (default: the config file's `server` key; errors if neither is set.)
871        #[arg(long)]
872        server: Option<String>,
873        tags: Vec<String>,
874    },
875    /// Write a value to one tag.
876    Write {
877        #[arg(long, env = "BHTUNE_BRIDGE_HOST")]
878        bridge_host: Option<String>,
879        #[arg(long)]
880        server: Option<String>,
881        tag: String,
882        value: String,
883    },
884    /// Browse one bounded page of tags. Without a session, lists the root level.
885    Browse {
886        #[arg(long, env = "BHTUNE_BRIDGE_HOST")]
887        bridge_host: Option<String>,
888        #[arg(long)]
889        server: Option<String>,
890        /// Existing bridge browse session to continue or use with `parent-node-key`.
891        #[arg(long)]
892        session_id: Option<String>,
893        /// Opaque node key returned by a previous page.
894        #[arg(long)]
895        parent_node_key: Option<String>,
896        /// Opaque continuation token returned by a previous page.
897        #[arg(long)]
898        page_token: Option<String>,
899        /// Number of immediate children to request.
900        #[arg(long, default_value_t = bhtune_driver::DEFAULT_PAGE_SIZE, value_parser = positive_u32)]
901        page_size: u32,
902        /// Follow continuation pages until the requested level is complete.
903        #[arg(long)]
904        all: bool,
905        /// Ask the gateway to refresh its namespace view.
906        #[arg(long)]
907        refresh: bool,
908    },
909    /// Explicitly release a gateway browse session returned by `opc browse`.
910    Close {
911        #[arg(long, env = "BHTUNE_BRIDGE_HOST")]
912        bridge_host: Option<String>,
913        /// Opaque browse-session ID returned by `opc browse`.
914        session_id: String,
915    },
916    /// Search the OPC DA namespace without downloading the whole tree.
917    Search {
918        #[arg(long, env = "BHTUNE_BRIDGE_HOST")]
919        bridge_host: Option<String>,
920        #[arg(long)]
921        server: Option<String>,
922        /// Text to find in node labels/item IDs.
923        query: String,
924        /// How the query should match.
925        #[arg(long, value_enum, default_value = "contains")]
926        match_mode: OpcSearchMatchModeArg,
927        /// Maximum number of matches.
928        #[arg(long, default_value_t = bhtune_driver::DEFAULT_SEARCH_MAX_RESULTS, value_parser = positive_u32)]
929        max_results: u32,
930        /// Existing bridge browse session to search within.
931        #[arg(long)]
932        session_id: Option<String>,
933        /// Opaque node key limiting the search scope.
934        #[arg(long)]
935        scope_node_key: Option<String>,
936        /// Include branch nodes as search results.
937        #[arg(long)]
938        include_branches: bool,
939        /// Ask the gateway to refresh its namespace view.
940        #[arg(long)]
941        refresh: bool,
942    },
943    /// Query and manage the gateway's persistent namespace search index.
944    SearchIndex {
945        #[command(subcommand)]
946        command: SearchIndexCommand,
947    },
948}
949
950#[derive(Subcommand, Debug)]
951pub enum SearchIndexCommand {
952    /// Show persistent namespace-index status.
953    Status {
954        #[arg(long, env = "BHTUNE_BRIDGE_HOST")]
955        bridge_host: Option<String>,
956        #[arg(long)]
957        server: Option<String>,
958    },
959    /// Search the persistent namespace index without traversing the live OPC tree.
960    Search {
961        #[arg(long, env = "BHTUNE_BRIDGE_HOST")]
962        bridge_host: Option<String>,
963        #[arg(long)]
964        server: Option<String>,
965        /// Text to find in indexed node labels/item IDs.
966        query: String,
967        /// How the query should match.
968        #[arg(long, value_enum, default_value = "contains")]
969        match_mode: OpcSearchMatchModeArg,
970        /// Maximum number of matches.
971        #[arg(long, default_value_t = bhtune_driver::DEFAULT_INDEX_SEARCH_MAX_RESULTS, value_parser = positive_u32)]
972        max_results: u32,
973    },
974    /// Start or coalesce a persistent namespace-index refresh.
975    Refresh {
976        #[arg(long, env = "BHTUNE_BRIDGE_HOST")]
977        bridge_host: Option<String>,
978        #[arg(long)]
979        server: Option<String>,
980        /// Start a refresh even when the active index is already current.
981        #[arg(long)]
982        force: bool,
983    },
984    /// Pause, resume, or cancel a persistent namespace-index build.
985    Control {
986        #[arg(long, env = "BHTUNE_BRIDGE_HOST")]
987        bridge_host: Option<String>,
988        #[arg(long)]
989        server: Option<String>,
990        #[arg(value_enum)]
991        action: SearchIndexControlActionArg,
992    },
993}
994
995#[derive(ValueEnum, Debug, Clone, Copy, PartialEq, Eq)]
996pub enum OpcSearchMatchModeArg {
997    Exact,
998    Prefix,
999    Contains,
1000}
1001
1002#[derive(ValueEnum, Debug, Clone, Copy, PartialEq, Eq)]
1003pub enum SearchIndexControlActionArg {
1004    Pause,
1005    Resume,
1006    Cancel,
1007}
1008
1009impl From<SearchIndexControlActionArg> for bhtune_driver::SearchIndexControlAction {
1010    fn from(value: SearchIndexControlActionArg) -> Self {
1011        match value {
1012            SearchIndexControlActionArg::Pause => bhtune_driver::SearchIndexControlAction::Pause,
1013            SearchIndexControlActionArg::Resume => bhtune_driver::SearchIndexControlAction::Resume,
1014            SearchIndexControlActionArg::Cancel => bhtune_driver::SearchIndexControlAction::Cancel,
1015        }
1016    }
1017}
1018
1019impl From<OpcSearchMatchModeArg> for bhtune_driver::SearchMatchMode {
1020    fn from(value: OpcSearchMatchModeArg) -> Self {
1021        match value {
1022            OpcSearchMatchModeArg::Exact => bhtune_driver::SearchMatchMode::Exact,
1023            OpcSearchMatchModeArg::Prefix => bhtune_driver::SearchMatchMode::Prefix,
1024            OpcSearchMatchModeArg::Contains => bhtune_driver::SearchMatchMode::Contains,
1025        }
1026    }
1027}
1028
1029#[cfg(test)]
1030mod tests {
1031    use super::*;
1032
1033    /// Downcasts `$value` to `$pattern`'s binding, panicking with `expected $label` otherwise.
1034    /// Every test below that parses a `Cli`/subcommand enum needs exactly this "or fail the
1035    /// test clearly" step; sharing one macro (rather than each call site's own `let-else {
1036    /// panic!(...) }`) means there is exactly one such panic branch in this file instead of
1037    /// four near-identical, individually-uncovered ones. `expect_variant_panics_on_a_mismatch`
1038    /// below is a dedicated test that deliberately trips it, so this one shared branch is
1039    /// itself covered rather than becoming a permanent, accepted gap.
1040    macro_rules! expect_variant {
1041        ($value:expr, $pattern:pat => $binding:expr, $label:literal) => {
1042            match $value {
1043                $pattern => $binding,
1044                _ => panic!("expected {}", $label),
1045            }
1046        };
1047    }
1048
1049    #[test]
1050    fn expect_variant_panics_on_a_mismatch() {
1051        let command = Cli::parse_from(["bhtune", "simulate"]).command;
1052        let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(
1053            || expect_variant!(command, Command::Tune(a) => a, "Tune"),
1054        ));
1055        let panic_message = *result.unwrap_err().downcast::<&str>().unwrap();
1056        assert_eq!(panic_message, "expected Tune");
1057    }
1058
1059    #[test]
1060    fn search_enum_conversions_cover_every_choice() {
1061        assert_eq!(
1062            bhtune_driver::SearchIndexControlAction::from(SearchIndexControlActionArg::Pause),
1063            bhtune_driver::SearchIndexControlAction::Pause
1064        );
1065        assert_eq!(
1066            bhtune_driver::SearchIndexControlAction::from(SearchIndexControlActionArg::Resume),
1067            bhtune_driver::SearchIndexControlAction::Resume
1068        );
1069        assert_eq!(
1070            bhtune_driver::SearchIndexControlAction::from(SearchIndexControlActionArg::Cancel),
1071            bhtune_driver::SearchIndexControlAction::Cancel
1072        );
1073        assert_eq!(
1074            bhtune_driver::SearchMatchMode::from(OpcSearchMatchModeArg::Prefix),
1075            bhtune_driver::SearchMatchMode::Prefix
1076        );
1077    }
1078
1079    #[test]
1080    fn history_command_output_format_covers_every_variant() {
1081        assert_eq!(
1082            HistoryCommand::List {
1083                outcome: None,
1084                limit: 50,
1085                offset: 0,
1086                output: crate::output::OutputFormat::Json,
1087            }
1088            .output_format(),
1089            crate::output::OutputFormat::Json
1090        );
1091        assert_eq!(
1092            HistoryCommand::Show {
1093                run_id: 1,
1094                output: crate::output::OutputFormat::Json,
1095            }
1096            .output_format(),
1097            crate::output::OutputFormat::Json
1098        );
1099        assert_eq!(
1100            HistoryCommand::Revert {
1101                run_id: 1,
1102                bridge_host: None,
1103                server: None,
1104                yes: false,
1105                output: crate::output::OutputFormat::Json,
1106            }
1107            .output_format(),
1108            crate::output::OutputFormat::Json
1109        );
1110        assert_eq!(
1111            HistoryCommand::Prune {
1112                older_than_days: None,
1113                dry_run: false,
1114                output: crate::output::OutputFormat::Json,
1115            }
1116            .output_format(),
1117            crate::output::OutputFormat::Json
1118        );
1119    }
1120
1121    #[test]
1122    fn command_output_format_forwards_simulate_and_history_formats() {
1123        let simulate = Cli::parse_from(["bhtune", "simulate", "--output", "json"]).command;
1124        assert_eq!(simulate.output_format(), crate::output::OutputFormat::Json);
1125
1126        let history = Cli::parse_from(["bhtune", "history", "list", "--output", "json"]).command;
1127        assert_eq!(history.output_format(), crate::output::OutputFormat::Json);
1128
1129        let restore =
1130            Cli::parse_from(["bhtune", "restore-loop", "12", "--yes", "--output", "json"]).command;
1131        assert_eq!(restore.output_format(), crate::output::OutputFormat::Json);
1132    }
1133
1134    #[test]
1135    fn check_command_accepts_tune_inputs_strict_and_readiness_only_writeback() {
1136        let cli = Cli::parse_from([
1137            "bhtune",
1138            "check",
1139            "--tagname",
1140            "Unit1.LIC101.PV",
1141            "--template",
1142            "Yokogawa CentumVP",
1143            "--process-type",
1144            "flow",
1145            "--controller-type",
1146            "pi",
1147            "--relay-amp",
1148            "10",
1149            "--driver",
1150            "opcda",
1151            "--server",
1152            "Matrikon.OPC.Simulation.1",
1153            "--write-pid",
1154            "moderate",
1155            "--strict",
1156            "--output",
1157            "json",
1158        ]);
1159        assert!(matches!(
1160            cli.command,
1161            Command::Check(args)
1162                if args.strict
1163                    && args.tune.write_pid == Some(ResponseLevelArg::Moderate)
1164                    && !args.tune.yes
1165                    && args.tune.output == crate::output::OutputFormat::Json
1166        ));
1167    }
1168
1169    #[test]
1170    fn finite_f32_accepts_finite_values_and_rejects_invalid_values() {
1171        assert_eq!(finite_f32("2.5").unwrap(), 2.5);
1172        assert!(finite_f32("not-a-number").is_err());
1173        assert!(finite_f32("nan").is_err());
1174        assert!(finite_f32("inf").is_err());
1175    }
1176
1177    #[test]
1178    fn positive_integer_parsers_reject_non_numeric_input() {
1179        assert_eq!(
1180            positive_u32("not-a-number").unwrap_err(),
1181            "'not-a-number' is not a valid non-negative integer"
1182        );
1183        assert_eq!(positive_u32("0").unwrap_err(), "must be at least 1");
1184    }
1185
1186    #[test]
1187    fn process_type_arg_converts_to_every_core_variant() {
1188        assert_eq!(
1189            bhtune_core::ProcessType::from(ProcessTypeArg::Flow),
1190            bhtune_core::ProcessType::Flow
1191        );
1192        assert_eq!(
1193            bhtune_core::ProcessType::from(ProcessTypeArg::PressureLine),
1194            bhtune_core::ProcessType::PressureLine
1195        );
1196        assert_eq!(
1197            bhtune_core::ProcessType::from(ProcessTypeArg::PressureVessel),
1198            bhtune_core::ProcessType::PressureVessel
1199        );
1200        assert_eq!(
1201            bhtune_core::ProcessType::from(ProcessTypeArg::Level),
1202            bhtune_core::ProcessType::Level
1203        );
1204        assert_eq!(
1205            bhtune_core::ProcessType::from(ProcessTypeArg::TemperatureMixing),
1206            bhtune_core::ProcessType::TemperatureMixing
1207        );
1208        assert_eq!(
1209            bhtune_core::ProcessType::from(ProcessTypeArg::TemperatureHeatExchange),
1210            bhtune_core::ProcessType::TemperatureHeatExchange
1211        );
1212    }
1213
1214    #[test]
1215    fn controller_type_arg_converts_to_every_core_variant() {
1216        assert_eq!(
1217            bhtune_core::ControllerType::from(ControllerTypeArg::P),
1218            bhtune_core::ControllerType::P
1219        );
1220        assert_eq!(
1221            bhtune_core::ControllerType::from(ControllerTypeArg::Pi),
1222            bhtune_core::ControllerType::Pi
1223        );
1224        assert_eq!(
1225            bhtune_core::ControllerType::from(ControllerTypeArg::Pid),
1226            bhtune_core::ControllerType::Pid
1227        );
1228    }
1229
1230    #[test]
1231    fn direction_arg_converts_to_every_core_variant() {
1232        assert_eq!(
1233            bhtune_core::ControllerDirection::from(DirectionArg::Direct),
1234            bhtune_core::ControllerDirection::Direct
1235        );
1236        assert_eq!(
1237            bhtune_core::ControllerDirection::from(DirectionArg::Reverse),
1238            bhtune_core::ControllerDirection::Reverse
1239        );
1240    }
1241
1242    #[test]
1243    fn process_type_converts_back_to_every_arg_variant() {
1244        assert_eq!(
1245            ProcessTypeArg::from(bhtune_core::ProcessType::Flow),
1246            ProcessTypeArg::Flow
1247        );
1248        assert_eq!(
1249            ProcessTypeArg::from(bhtune_core::ProcessType::PressureLine),
1250            ProcessTypeArg::PressureLine
1251        );
1252        assert_eq!(
1253            ProcessTypeArg::from(bhtune_core::ProcessType::PressureVessel),
1254            ProcessTypeArg::PressureVessel
1255        );
1256        assert_eq!(
1257            ProcessTypeArg::from(bhtune_core::ProcessType::Level),
1258            ProcessTypeArg::Level
1259        );
1260        assert_eq!(
1261            ProcessTypeArg::from(bhtune_core::ProcessType::TemperatureMixing),
1262            ProcessTypeArg::TemperatureMixing
1263        );
1264        assert_eq!(
1265            ProcessTypeArg::from(bhtune_core::ProcessType::TemperatureHeatExchange),
1266            ProcessTypeArg::TemperatureHeatExchange
1267        );
1268    }
1269
1270    #[test]
1271    fn controller_type_converts_back_to_every_arg_variant() {
1272        assert_eq!(
1273            ControllerTypeArg::from(bhtune_core::ControllerType::P),
1274            ControllerTypeArg::P
1275        );
1276        assert_eq!(
1277            ControllerTypeArg::from(bhtune_core::ControllerType::Pi),
1278            ControllerTypeArg::Pi
1279        );
1280        assert_eq!(
1281            ControllerTypeArg::from(bhtune_core::ControllerType::Pid),
1282            ControllerTypeArg::Pid
1283        );
1284    }
1285
1286    #[test]
1287    fn direction_converts_back_to_every_arg_variant() {
1288        assert_eq!(
1289            DirectionArg::from(bhtune_core::ControllerDirection::Direct),
1290            DirectionArg::Direct
1291        );
1292        assert_eq!(
1293            DirectionArg::from(bhtune_core::ControllerDirection::Reverse),
1294            DirectionArg::Reverse
1295        );
1296    }
1297
1298    #[test]
1299    fn driver_kind_arg_try_from_tune_driver_covers_the_implemented_drivers() {
1300        assert_eq!(
1301            DriverKindArg::try_from(bhtune_db::models::TuneDriver::Opcda).unwrap(),
1302            DriverKindArg::Opcda
1303        );
1304        assert_eq!(
1305            DriverKindArg::try_from(bhtune_db::models::TuneDriver::Simulator).unwrap(),
1306            DriverKindArg::Simulator
1307        );
1308    }
1309
1310    #[test]
1311    fn driver_kind_arg_try_from_tune_driver_rejects_replay() {
1312        let err = DriverKindArg::try_from(bhtune_db::models::TuneDriver::Replay).unwrap_err();
1313        assert_eq!(err, ReplayDriverUnsupported);
1314        assert!(err.to_string().contains("replay"));
1315    }
1316
1317    #[test]
1318    fn outcome_arg_converts_to_every_db_variant() {
1319        assert_eq!(
1320            bhtune_db::models::TuneOutcome::from(OutcomeArg::Running),
1321            bhtune_db::models::TuneOutcome::Running
1322        );
1323        assert_eq!(
1324            bhtune_db::models::TuneOutcome::from(OutcomeArg::Completed),
1325            bhtune_db::models::TuneOutcome::Completed
1326        );
1327        assert_eq!(
1328            bhtune_db::models::TuneOutcome::from(OutcomeArg::Failed),
1329            bhtune_db::models::TuneOutcome::Failed
1330        );
1331        assert_eq!(
1332            bhtune_db::models::TuneOutcome::from(OutcomeArg::Aborted),
1333            bhtune_db::models::TuneOutcome::Aborted
1334        );
1335    }
1336
1337    #[test]
1338    fn simulate_args_expand_into_tune_args_with_simulator_driver() {
1339        let cli = Cli::parse_from(["bhtune", "simulate"]);
1340        let simulate = expect_variant!(cli.command, Command::Simulate(s) => s, "Simulate");
1341        let tune = simulate.into_tune_args();
1342        assert!(matches!(tune.driver, DriverKindArg::Simulator));
1343        assert_eq!(tune.tagname, "Sim.Loop1.PV");
1344        assert_eq!(tune.pv_range_low, Some(0.0));
1345        assert_eq!(tune.pv_range_high, Some(100.0));
1346        assert_eq!(tune.mv_range_low, Some(0.0));
1347        assert_eq!(tune.mv_range_high, Some(100.0));
1348        assert!(matches!(tune.direction, Some(DirectionArg::Reverse)));
1349        assert!(!tune.yes);
1350        assert!(tune.write_pid.is_none());
1351        assert_eq!(tune.output, crate::output::OutputFormat::Table);
1352    }
1353
1354    #[test]
1355    fn simulate_args_expand_into_tune_args_carries_yes_write_pid_and_output_through() {
1356        let cli = Cli::parse_from([
1357            "bhtune",
1358            "simulate",
1359            "--yes",
1360            "--write-pid",
1361            "sluggish",
1362            "--output",
1363            "json",
1364        ]);
1365        let simulate = expect_variant!(cli.command, Command::Simulate(s) => s, "Simulate");
1366        let tune = simulate.into_tune_args();
1367        assert!(tune.yes);
1368        assert!(matches!(tune.write_pid, Some(ResponseLevelArg::Sluggish)));
1369        assert_eq!(tune.output, crate::output::OutputFormat::Json);
1370    }
1371
1372    #[test]
1373    fn response_level_arg_converts_to_every_core_variant() {
1374        assert_eq!(
1375            bhtune_core::ResponseLevel::from(ResponseLevelArg::Aggressive),
1376            bhtune_core::ResponseLevel::Aggressive
1377        );
1378        assert_eq!(
1379            bhtune_core::ResponseLevel::from(ResponseLevelArg::Moderate),
1380            bhtune_core::ResponseLevel::Moderate
1381        );
1382        assert_eq!(
1383            bhtune_core::ResponseLevel::from(ResponseLevelArg::Sluggish),
1384            bhtune_core::ResponseLevel::Sluggish
1385        );
1386    }
1387
1388    #[test]
1389    fn response_level_converts_back_to_every_arg_variant() {
1390        assert_eq!(
1391            ResponseLevelArg::from(bhtune_core::ResponseLevel::Aggressive),
1392            ResponseLevelArg::Aggressive
1393        );
1394        assert_eq!(
1395            ResponseLevelArg::from(bhtune_core::ResponseLevel::Moderate),
1396            ResponseLevelArg::Moderate
1397        );
1398        assert_eq!(
1399            ResponseLevelArg::from(bhtune_core::ResponseLevel::Sluggish),
1400            ResponseLevelArg::Sluggish
1401        );
1402    }
1403
1404    #[test]
1405    fn cli_parses_a_full_tune_command() {
1406        let cli = Cli::parse_from([
1407            "bhtune",
1408            "tune",
1409            "-t",
1410            "Unit1.LIC101.PV",
1411            "--template",
1412            "Yokogawa CentumVP",
1413            "--process-type",
1414            "flow",
1415            "--controller-type",
1416            "pi",
1417            "--relay-amp",
1418            "5.0",
1419            "--driver",
1420            "simulator",
1421        ]);
1422        let args = expect_variant!(cli.command, Command::Tune(a) => a, "Tune");
1423        assert_eq!(args.tagname, "Unit1.LIC101.PV");
1424        assert!(matches!(args.process_type, ProcessTypeArg::Flow));
1425        assert!(matches!(args.controller_type, ControllerTypeArg::Pi));
1426        assert!(matches!(args.driver, DriverKindArg::Simulator));
1427        assert!(!args.yes);
1428        assert!(args.write_pid.is_none());
1429        assert_eq!(args.output, crate::output::OutputFormat::Table);
1430    }
1431
1432    #[test]
1433    fn cli_parses_tune_yes_and_write_pid_flags() {
1434        let cli = Cli::parse_from([
1435            "bhtune",
1436            "tune",
1437            "-t",
1438            "Unit1.LIC101.PV",
1439            "--template",
1440            "Yokogawa CentumVP",
1441            "--process-type",
1442            "flow",
1443            "--controller-type",
1444            "pi",
1445            "--relay-amp",
1446            "5.0",
1447            "--driver",
1448            "simulator",
1449            "--yes",
1450            "--write-pid",
1451            "moderate",
1452            "--output",
1453            "json",
1454        ]);
1455        let args = expect_variant!(cli.command, Command::Tune(a) => a, "Tune");
1456        assert!(args.yes);
1457        assert!(matches!(args.write_pid, Some(ResponseLevelArg::Moderate)));
1458        assert_eq!(args.output, crate::output::OutputFormat::Json);
1459    }
1460
1461    #[test]
1462    fn cli_rejects_per_run_timing_flags() {
1463        for flag in [
1464            "--mrft-delay",
1465            "--poll-interval-ms",
1466            "--timeout-secs",
1467            "--op-timeout-secs",
1468            "--restore-timeout-secs",
1469        ] {
1470            let result = Cli::try_parse_from(["bhtune", "simulate", flag, "1"]);
1471            assert!(result.is_err(), "{flag} must remain global-config-only");
1472        }
1473    }
1474
1475    #[test]
1476    fn cli_parses_history_list_with_filters() {
1477        let cli = Cli::parse_from([
1478            "bhtune",
1479            "history",
1480            "list",
1481            "--outcome",
1482            "completed",
1483            "--limit",
1484            "10",
1485        ]);
1486        let command =
1487            expect_variant!(cli.command, Command::History { command } => command, "History");
1488        let (outcome, limit, offset, output) = expect_variant!(
1489            command,
1490            HistoryCommand::List { outcome, limit, offset, output } => (outcome, limit, offset, output),
1491            "List"
1492        );
1493        assert!(matches!(outcome, Some(OutcomeArg::Completed)));
1494        assert_eq!(limit, 10);
1495        assert_eq!(offset, 0);
1496        assert_eq!(output, crate::output::OutputFormat::Table);
1497    }
1498
1499    #[test]
1500    fn cli_parses_history_list_and_show_with_output_json() {
1501        let cli = Cli::parse_from(["bhtune", "history", "list", "--output", "json"]);
1502        let command =
1503            expect_variant!(cli.command, Command::History { command } => command, "History");
1504        assert_eq!(command.output_format(), crate::output::OutputFormat::Json);
1505
1506        let cli = Cli::parse_from(["bhtune", "history", "show", "42", "--output", "json"]);
1507        let command =
1508            expect_variant!(cli.command, Command::History { command } => command, "History");
1509        let (run_id, output) = expect_variant!(
1510            command,
1511            HistoryCommand::Show { run_id, output } => (run_id, output),
1512            "Show"
1513        );
1514        assert_eq!(run_id, 42);
1515        assert_eq!(output, crate::output::OutputFormat::Json);
1516    }
1517
1518    #[test]
1519    fn cli_parses_history_prune_defaults() {
1520        let cli = Cli::parse_from(["bhtune", "history", "prune"]);
1521        let command =
1522            expect_variant!(cli.command, Command::History { command } => command, "History");
1523        let (older_than_days, dry_run, output) = expect_variant!(
1524            command,
1525            HistoryCommand::Prune { older_than_days, dry_run, output } => (older_than_days, dry_run, output),
1526            "Prune"
1527        );
1528        assert_eq!(older_than_days, None);
1529        assert!(!dry_run);
1530        assert_eq!(output, crate::output::OutputFormat::Table);
1531    }
1532
1533    #[test]
1534    fn cli_parses_history_prune_with_older_than_days_and_dry_run() {
1535        let cli = Cli::parse_from([
1536            "bhtune",
1537            "history",
1538            "prune",
1539            "--older-than-days",
1540            "14",
1541            "--dry-run",
1542            "--output",
1543            "json",
1544        ]);
1545        let command =
1546            expect_variant!(cli.command, Command::History { command } => command, "History");
1547        let (older_than_days, dry_run, output) = expect_variant!(
1548            command,
1549            HistoryCommand::Prune { older_than_days, dry_run, output } => (older_than_days, dry_run, output),
1550            "Prune"
1551        );
1552        assert_eq!(older_than_days, Some(14));
1553        assert!(dry_run);
1554        assert_eq!(output, crate::output::OutputFormat::Json);
1555    }
1556
1557    #[test]
1558    fn cli_rejects_a_zero_history_prune_older_than_days() {
1559        let result = Cli::try_parse_from(["bhtune", "history", "prune", "--older-than-days", "0"]);
1560        assert!(result.is_err());
1561    }
1562
1563    #[test]
1564    fn command_output_format_defaults_to_table_for_commands_without_the_concept() {
1565        let cli = Cli::parse_from(["bhtune", "template", "list"]);
1566        assert_eq!(
1567            cli.command.output_format(),
1568            crate::output::OutputFormat::Table
1569        );
1570
1571        let cli = Cli::parse_from(["bhtune", "opc", "read", "Unit1.LIC101.PV"]);
1572        assert_eq!(
1573            cli.command.output_format(),
1574            crate::output::OutputFormat::Table
1575        );
1576
1577        let cli = Cli::parse_from(["bhtune", "export", "1"]);
1578        assert_eq!(
1579            cli.command.output_format(),
1580            crate::output::OutputFormat::Table
1581        );
1582
1583        let cli = Cli::parse_from(["bhtune", "simulate", "--output", "json"]);
1584        assert_eq!(
1585            cli.command.output_format(),
1586            crate::output::OutputFormat::Json
1587        );
1588    }
1589
1590    #[test]
1591    fn cli_rejects_missing_required_tune_flags() {
1592        let result = Cli::try_parse_from(["bhtune", "tune"]);
1593        assert!(result.is_err());
1594    }
1595
1596    #[test]
1597    fn cli_db_config_and_templates_default_to_none() {
1598        let cli = Cli::parse_from(["bhtune", "simulate"]);
1599        assert_eq!(cli.db, None);
1600        assert_eq!(cli.config, None);
1601        assert_eq!(cli.templates, None);
1602        assert_eq!(cli.retention_days, None);
1603    }
1604
1605    #[test]
1606    fn cli_parses_explicit_db_config_and_templates_flags() {
1607        let cli = Cli::parse_from([
1608            "bhtune",
1609            "--db",
1610            "/data/bhtune.db",
1611            "--config",
1612            "/etc/bhtune.toml",
1613            "--templates",
1614            "/etc/bhtune/templates.toml",
1615            "--retention-days",
1616            "30",
1617            "simulate",
1618        ]);
1619        assert_eq!(cli.db, Some(PathBuf::from("/data/bhtune.db")));
1620        assert_eq!(cli.config, Some(PathBuf::from("/etc/bhtune.toml")));
1621        assert_eq!(
1622            cli.templates,
1623            Some(PathBuf::from("/etc/bhtune/templates.toml"))
1624        );
1625        assert_eq!(cli.retention_days, Some(30));
1626    }
1627
1628    #[test]
1629    fn cli_rejects_a_zero_retention_days() {
1630        // `positive_u32` -- `0` is a nonsensical "delete everything immediately" policy, not
1631        // a legitimate "keep nothing older than zero days" configuration.
1632        let result = Cli::try_parse_from(["bhtune", "--retention-days", "0", "simulate"]);
1633        assert!(result.is_err());
1634    }
1635
1636    #[test]
1637    fn cli_log_flags_default_to_none() {
1638        let cli = Cli::parse_from(["bhtune", "simulate"]);
1639        assert_eq!(cli.log_level, None);
1640        assert_eq!(cli.log_dir, None);
1641        assert_eq!(cli.log_format, None);
1642        assert_eq!(cli.log_rotation, None);
1643    }
1644
1645    #[test]
1646    fn cli_parses_explicit_log_flags() {
1647        let cli = Cli::parse_from([
1648            "bhtune",
1649            "--log-level",
1650            "debug",
1651            "--log-dir",
1652            "/var/log/bhtune",
1653            "--log-format",
1654            "json",
1655            "--log-rotation",
1656            "hourly",
1657            "simulate",
1658        ]);
1659        assert_eq!(cli.log_level, Some("debug".to_string()));
1660        assert_eq!(cli.log_dir, Some(PathBuf::from("/var/log/bhtune")));
1661        assert_eq!(cli.log_format, Some("json".to_string()));
1662        assert_eq!(cli.log_rotation, Some("hourly".to_string()));
1663    }
1664
1665    #[test]
1666    fn tune_args_bridge_host_defaults_to_none() {
1667        let cli = Cli::parse_from([
1668            "bhtune",
1669            "tune",
1670            "-t",
1671            "Unit1.LIC101.PV",
1672            "--template",
1673            "Yokogawa CentumVP",
1674            "--process-type",
1675            "flow",
1676            "--controller-type",
1677            "pi",
1678            "--relay-amp",
1679            "5.0",
1680            "--driver",
1681            "simulator",
1682        ]);
1683        let args = expect_variant!(cli.command, Command::Tune(a) => a, "Tune");
1684        assert_eq!(args.bridge_host, None);
1685    }
1686
1687    #[test]
1688    fn opc_servers_bridge_host_defaults_to_none() {
1689        let cli = Cli::parse_from(["bhtune", "opc", "servers"]);
1690        let command = expect_variant!(cli.command, Command::Opc { command, .. } => command, "Opc");
1691        let bridge_host =
1692            expect_variant!(command, OpcCommand::Servers { bridge_host } => bridge_host, "Servers");
1693        assert_eq!(bridge_host, None);
1694    }
1695
1696    #[test]
1697    fn opc_gateway_info_bridge_host_defaults_to_none() {
1698        let cli = Cli::parse_from(["bhtune", "opc", "gateway-info"]);
1699        let command = expect_variant!(cli.command, Command::Opc { command, .. } => command, "Opc");
1700        let bridge_host = expect_variant!(
1701            command,
1702            OpcCommand::GatewayInfo { bridge_host } => bridge_host,
1703            "GatewayInfo"
1704        );
1705        assert_eq!(bridge_host, None);
1706    }
1707
1708    #[test]
1709    fn opc_read_bridge_host_and_server_default_to_none() {
1710        let cli = Cli::parse_from(["bhtune", "opc", "read", "Unit1.LIC101.PV"]);
1711        let command = expect_variant!(cli.command, Command::Opc { command, .. } => command, "Opc");
1712        let (bridge_host, server) = expect_variant!(
1713            command,
1714            OpcCommand::Read { bridge_host, server, .. } => (bridge_host, server),
1715            "Read"
1716        );
1717        assert_eq!(bridge_host, None);
1718        assert_eq!(server, None);
1719    }
1720}