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