1use std::path::PathBuf;
10
11use bhtune_core::TagOverrides;
12use clap::{Parser, Subcommand, ValueEnum};
13
14fn 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
31fn 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#[derive(Parser, Debug)]
47#[command(name = "bhtune", version, about = "Headless MRFT auto-tuner")]
48pub struct Cli {
49 #[arg(long, global = true, value_name = "PATH")]
51 pub config: Option<PathBuf>,
52
53 #[arg(long, global = true, env = "BHTUNE_DB", value_name = "PATH")]
57 pub db: Option<PathBuf>,
58
59 #[arg(long, global = true, env = "BHTUNE_TEMPLATES", value_name = "PATH")]
66 pub templates: Option<PathBuf>,
67
68 #[arg(long, global = true, env = "BHTUNE_RETENTION_DAYS", value_parser = positive_u32)]
75 pub retention_days: Option<u32>,
76
77 #[arg(long, global = true, env = "RUST_LOG")]
81 pub log_level: Option<String>,
82
83 #[arg(long, global = true, value_name = "PATH")]
86 pub log_dir: Option<PathBuf>,
87
88 #[arg(long, global = true)]
90 pub log_format: Option<String>,
91
92 #[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 Tune(TuneArgs),
105 Simulate(SimulateArgs),
107 Template {
109 #[command(subcommand)]
110 command: TemplateCommand,
111 },
112 History {
114 #[command(subcommand)]
115 command: HistoryCommand,
116 },
117 Export(ExportArgs),
119 Opc {
122 #[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 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#[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
174impl 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#[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
212impl 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#[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
240impl 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#[derive(ValueEnum, Debug, Clone, Copy, PartialEq, Eq)]
253pub enum DriverKindArg {
254 Opcda,
256 Simulator,
258}
259
260impl 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#[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#[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
318impl 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#[derive(Parser, Debug, Clone)]
332pub struct TuneArgs {
333 #[arg(short = 't', long)]
337 pub tagname: String,
338
339 #[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 #[arg(long, value_parser = finite_f32)]
351 pub relay_amp: f32,
352
353 #[arg(long)]
355 pub cycles_skip: Option<u32>,
356
357 #[arg(long, value_parser = positive_u32)]
360 pub cycles_count: Option<u32>,
361
362 #[arg(long)]
365 pub noise_protection_secs: Option<u32>,
366
367 #[cfg(test)]
370 #[arg(skip)]
371 pub mrft_delay: u32,
372
373 #[arg(long, value_enum)]
375 pub driver: DriverKindArg,
376
377 #[arg(long, env = "BHTUNE_BRIDGE_HOST")]
382 pub bridge_host: Option<String>,
383
384 #[arg(long)]
386 pub server: Option<String>,
387
388 #[arg(long, default_value_t = 1.0, value_parser = finite_f32)]
390 pub sim_gain: f32,
391 #[arg(long, default_value_t = 2.0, value_parser = finite_f32)]
393 pub sim_tau: f32,
394 #[arg(long, default_value_t = 5.0, value_parser = finite_f32)]
396 pub sim_dead_time: f32,
397 #[arg(long, default_value_t = 0.0, value_parser = finite_f32)]
399 pub sim_noise: f32,
400 #[arg(long, default_value_t = 0)]
402 pub sim_seed: u64,
403 #[arg(long, default_value_t = 50.0, value_parser = finite_f32)]
405 pub sim_initial_pv: f32,
406 #[arg(long, default_value_t = 50.0, value_parser = finite_f32)]
408 pub sim_initial_mv: f32,
409
410 #[arg(long, value_parser = finite_f32)]
414 pub pv_range_high: Option<f32>,
415 #[arg(long, value_parser = finite_f32)]
417 pub pv_range_low: Option<f32>,
418 #[arg(long, value_parser = finite_f32)]
420 pub mv_range_high: Option<f32>,
421 #[arg(long, value_parser = finite_f32)]
423 pub mv_range_low: Option<f32>,
424 #[arg(long, value_enum)]
426 pub direction: Option<DirectionArg>,
427
428 #[arg(skip)]
431 pub tag_overrides: Option<TagOverrides>,
432
433 #[cfg(test)]
436 #[arg(skip)]
437 pub poll_interval_ms: u64,
438
439 #[cfg(test)]
442 #[arg(skip)]
443 pub timeout_secs: u64,
444
445 #[arg(long)]
448 pub notes: Option<String>,
449
450 #[arg(long)]
454 pub yes: bool,
455
456 #[arg(long, value_enum)]
460 pub write_pid: Option<ResponseLevelArg>,
461
462 #[cfg(test)]
465 #[arg(skip)]
466 pub op_timeout_secs: u64,
467
468 #[cfg(test)]
471 #[arg(skip)]
472 pub restore_timeout_secs: u64,
473
474 #[arg(long, value_enum, default_value = "table")]
476 pub output: crate::output::OutputFormat,
477}
478
479#[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 #[cfg(test)]
529 #[arg(skip)]
530 pub timeout_secs: u64,
531
532 #[arg(long)]
534 pub notes: Option<String>,
535
536 #[arg(long)]
538 pub yes: bool,
539
540 #[arg(long, value_enum)]
545 pub write_pid: Option<ResponseLevelArg>,
546
547 #[cfg(test)]
550 #[arg(skip)]
551 pub op_timeout_secs: u64,
552
553 #[cfg(test)]
556 #[arg(skip)]
557 pub restore_timeout_secs: u64,
558
559 #[arg(long, value_enum, default_value = "table")]
561 pub output: crate::output::OutputFormat,
562}
563
564impl SimulateArgs {
565 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 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,
619 Show { name: String },
621 Import { path: PathBuf },
630 Export {
633 name: String,
634 path: PathBuf,
635 #[arg(long, value_enum, default_value = "json")]
638 format: TemplateFileFormat,
639 },
640 Delete { name: String },
645}
646
647#[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 {
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 #[arg(long, value_enum, default_value = "table")]
666 output: crate::output::OutputFormat,
667 },
668 Show {
671 run_id: i64,
672 #[arg(long, value_enum, default_value = "table")]
674 output: crate::output::OutputFormat,
675 },
676 Revert {
681 run_id: i64,
682 #[arg(long)]
690 bridge_host: Option<String>,
691 #[arg(long)]
696 server: Option<String>,
697 #[arg(long)]
701 yes: bool,
702 #[arg(long, value_enum, default_value = "table")]
704 output: crate::output::OutputFormat,
705 },
706 Prune {
709 #[arg(long, value_parser = positive_u32)]
715 older_than_days: Option<u32>,
716 #[arg(long)]
719 dry_run: bool,
720 #[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#[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 #[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 Servers {
777 #[arg(long, env = "BHTUNE_BRIDGE_HOST")]
780 bridge_host: Option<String>,
781 },
782 Read {
784 #[arg(long, env = "BHTUNE_BRIDGE_HOST")]
787 bridge_host: Option<String>,
788 #[arg(long)]
790 server: Option<String>,
791 tags: Vec<String>,
792 },
793 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 {
804 #[arg(long, env = "BHTUNE_BRIDGE_HOST")]
805 bridge_host: Option<String>,
806 #[arg(long)]
807 server: Option<String>,
808 #[arg(long)]
810 session_id: Option<String>,
811 #[arg(long)]
813 parent_node_key: Option<String>,
814 #[arg(long)]
816 page_token: Option<String>,
817 #[arg(long, default_value_t = bhtune_driver::DEFAULT_PAGE_SIZE, value_parser = positive_u32)]
819 page_size: u32,
820 #[arg(long)]
822 all: bool,
823 #[arg(long)]
825 refresh: bool,
826 },
827 Close {
829 #[arg(long, env = "BHTUNE_BRIDGE_HOST")]
830 bridge_host: Option<String>,
831 session_id: String,
833 },
834 Search {
836 #[arg(long, env = "BHTUNE_BRIDGE_HOST")]
837 bridge_host: Option<String>,
838 #[arg(long)]
839 server: Option<String>,
840 query: String,
842 #[arg(long, value_enum, default_value = "contains")]
844 match_mode: OpcSearchMatchModeArg,
845 #[arg(long, default_value_t = bhtune_driver::DEFAULT_SEARCH_MAX_RESULTS, value_parser = positive_u32)]
847 max_results: u32,
848 #[arg(long)]
850 session_id: Option<String>,
851 #[arg(long)]
853 scope_node_key: Option<String>,
854 #[arg(long)]
856 include_branches: bool,
857 #[arg(long)]
859 refresh: bool,
860 },
861 SearchIndex {
863 #[command(subcommand)]
864 command: SearchIndexCommand,
865 },
866}
867
868#[derive(Subcommand, Debug)]
869pub enum SearchIndexCommand {
870 Status {
872 #[arg(long, env = "BHTUNE_BRIDGE_HOST")]
873 bridge_host: Option<String>,
874 #[arg(long)]
875 server: Option<String>,
876 },
877 Search {
879 #[arg(long, env = "BHTUNE_BRIDGE_HOST")]
880 bridge_host: Option<String>,
881 #[arg(long)]
882 server: Option<String>,
883 query: String,
885 #[arg(long, value_enum, default_value = "contains")]
887 match_mode: OpcSearchMatchModeArg,
888 #[arg(long, default_value_t = bhtune_driver::DEFAULT_INDEX_SEARCH_MAX_RESULTS, value_parser = positive_u32)]
890 max_results: u32,
891 },
892 Refresh {
894 #[arg(long, env = "BHTUNE_BRIDGE_HOST")]
895 bridge_host: Option<String>,
896 #[arg(long)]
897 server: Option<String>,
898 #[arg(long)]
900 force: bool,
901 },
902 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 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 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}