1use std::path::PathBuf;
10
11use bhtune_core::TagOverrides;
12use bhtune_runtime::tune::{
13 DEFAULT_SIM_DEAD_TIME, DEFAULT_SIM_GAIN, DEFAULT_SIM_INITIAL_VALUE, DEFAULT_SIM_NOISE,
14 DEFAULT_SIM_SEED, DEFAULT_SIM_TAU, DriverKind, TuneRequest, TuneRequestValidationError,
15 ValidatedTuneRequest, validate_finite_f32, validate_positive_u32,
16};
17use clap::{Args, Parser, Subcommand, ValueEnum};
18
19fn finite_f32(s: &str) -> Result<f32, String> {
25 let value: f32 = s
26 .parse()
27 .map_err(|_| format!("'{s}' is not a valid number"))?;
28 validate_finite_f32("value", value)
29 .map_err(|_| format!("'{s}' must be a finite number (not NaN or infinite)"))?;
30 Ok(value)
31}
32
33fn positive_u32(s: &str) -> Result<u32, String> {
38 let value: u32 = s
39 .parse()
40 .map_err(|_| format!("'{s}' is not a valid non-negative integer"))?;
41 validate_positive_u32("value", value).map_err(|_| "must be at least 1".to_string())?;
42 Ok(value)
43}
44
45#[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")]
82 pub log_level: Option<String>,
83
84 #[arg(long, global = true, value_name = "PATH")]
87 pub log_dir: Option<PathBuf>,
88
89 #[arg(long, global = true)]
91 pub log_format: Option<String>,
92
93 #[arg(long, global = true)]
95 pub log_rotation: Option<String>,
96
97 #[command(subcommand)]
98 pub command: Command,
99}
100
101#[derive(Subcommand, Debug)]
102#[allow(clippy::large_enum_variant)]
103pub enum Command {
104 Check(CheckArgs),
106 Tune(TuneArgs),
108 Simulate(SimulateArgs),
110 Template {
112 #[command(subcommand)]
113 command: TemplateCommand,
114 },
115 History {
117 #[command(subcommand)]
118 command: HistoryCommand,
119 },
120 RestoreLoop(RestoreLoopArgs),
122 Export(ExportArgs),
124 Opc {
127 #[arg(long, global = true, value_enum, default_value = "table")]
129 output: crate::output::OutputFormat,
130 #[command(subcommand)]
131 command: OpcCommand,
132 },
133}
134
135impl Command {
136 pub(crate) fn output_format(&self) -> crate::output::OutputFormat {
143 match self {
144 Command::Check(args) => args.tune.output,
145 Command::Tune(args) => args.output,
146 Command::Simulate(args) => args.output,
147 Command::History { command } => command.output_format(),
148 Command::RestoreLoop(args) => args.output,
149 Command::Template { .. } | Command::Export(_) => crate::output::OutputFormat::Table,
150 Command::Opc { output, .. } => *output,
151 }
152 }
153}
154
155#[derive(Args, Debug, Clone, PartialEq, Eq)]
156pub struct RestoreLoopArgs {
157 pub run_id: i64,
159 #[arg(long)]
161 pub bridge_host: Option<String>,
162 #[arg(long)]
164 pub server: Option<String>,
165 #[arg(long)]
167 pub yes: bool,
168 #[arg(long, value_enum, default_value = "table")]
170 pub output: crate::output::OutputFormat,
171}
172
173#[derive(ValueEnum, Debug, Clone, Copy, PartialEq, Eq)]
175pub enum ProcessTypeArg {
176 Flow,
177 PressureLine,
178 PressureVessel,
179 Level,
180 TemperatureMixing,
181 TemperatureHeatExchange,
182}
183
184impl From<ProcessTypeArg> for bhtune_core::ProcessType {
185 fn from(value: ProcessTypeArg) -> Self {
186 match value {
187 ProcessTypeArg::Flow => bhtune_core::ProcessType::Flow,
188 ProcessTypeArg::PressureLine => bhtune_core::ProcessType::PressureLine,
189 ProcessTypeArg::PressureVessel => bhtune_core::ProcessType::PressureVessel,
190 ProcessTypeArg::Level => bhtune_core::ProcessType::Level,
191 ProcessTypeArg::TemperatureMixing => bhtune_core::ProcessType::TemperatureMixing,
192 ProcessTypeArg::TemperatureHeatExchange => {
193 bhtune_core::ProcessType::TemperatureHeatExchange
194 }
195 }
196 }
197}
198
199impl From<bhtune_core::ProcessType> for ProcessTypeArg {
205 fn from(value: bhtune_core::ProcessType) -> Self {
206 match value {
207 bhtune_core::ProcessType::Flow => ProcessTypeArg::Flow,
208 bhtune_core::ProcessType::PressureLine => ProcessTypeArg::PressureLine,
209 bhtune_core::ProcessType::PressureVessel => ProcessTypeArg::PressureVessel,
210 bhtune_core::ProcessType::Level => ProcessTypeArg::Level,
211 bhtune_core::ProcessType::TemperatureMixing => ProcessTypeArg::TemperatureMixing,
212 bhtune_core::ProcessType::TemperatureHeatExchange => {
213 ProcessTypeArg::TemperatureHeatExchange
214 }
215 }
216 }
217}
218
219#[derive(ValueEnum, Debug, Clone, Copy, PartialEq, Eq)]
221pub enum ControllerTypeArg {
222 P,
223 Pi,
224 Pid,
225}
226
227impl From<ControllerTypeArg> for bhtune_core::ControllerType {
228 fn from(value: ControllerTypeArg) -> Self {
229 match value {
230 ControllerTypeArg::P => bhtune_core::ControllerType::P,
231 ControllerTypeArg::Pi => bhtune_core::ControllerType::Pi,
232 ControllerTypeArg::Pid => bhtune_core::ControllerType::Pid,
233 }
234 }
235}
236
237impl From<bhtune_core::ControllerType> for ControllerTypeArg {
240 fn from(value: bhtune_core::ControllerType) -> Self {
241 match value {
242 bhtune_core::ControllerType::P => ControllerTypeArg::P,
243 bhtune_core::ControllerType::Pi => ControllerTypeArg::Pi,
244 bhtune_core::ControllerType::Pid => ControllerTypeArg::Pid,
245 }
246 }
247}
248
249#[derive(ValueEnum, Debug, Clone, Copy, PartialEq, Eq)]
251pub enum DirectionArg {
252 Direct,
253 Reverse,
254}
255
256impl From<DirectionArg> for bhtune_core::ControllerDirection {
257 fn from(value: DirectionArg) -> Self {
258 match value {
259 DirectionArg::Direct => bhtune_core::ControllerDirection::Direct,
260 DirectionArg::Reverse => bhtune_core::ControllerDirection::Reverse,
261 }
262 }
263}
264
265impl From<bhtune_core::ControllerDirection> for DirectionArg {
268 fn from(value: bhtune_core::ControllerDirection) -> Self {
269 match value {
270 bhtune_core::ControllerDirection::Direct => DirectionArg::Direct,
271 bhtune_core::ControllerDirection::Reverse => DirectionArg::Reverse,
272 }
273 }
274}
275
276#[derive(ValueEnum, Debug, Clone, Copy, PartialEq, Eq)]
278pub enum DriverKindArg {
279 Opcda,
281 Simulator,
283}
284
285impl TryFrom<bhtune_db::models::TuneDriver> for DriverKindArg {
296 type Error = ReplayDriverUnsupported;
297
298 fn try_from(value: bhtune_db::models::TuneDriver) -> Result<Self, Self::Error> {
299 match value {
300 bhtune_db::models::TuneDriver::Opcda => Ok(DriverKindArg::Opcda),
301 bhtune_db::models::TuneDriver::Simulator => Ok(DriverKindArg::Simulator),
302 bhtune_db::models::TuneDriver::Replay => Err(ReplayDriverUnsupported),
303 }
304 }
305}
306
307#[derive(Debug, Clone, Copy, PartialEq, Eq)]
310pub struct ReplayDriverUnsupported;
311
312impl std::fmt::Display for ReplayDriverUnsupported {
313 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
314 write!(
315 f,
316 "the replay driver cannot be used to start a new tune run (it exists only for \
317 offline golden-trace validation, not live/simulated tuning)"
318 )
319 }
320}
321
322impl std::error::Error for ReplayDriverUnsupported {}
323
324#[derive(ValueEnum, Debug, Clone, Copy, PartialEq, Eq)]
327pub enum ResponseLevelArg {
328 Aggressive,
329 Moderate,
330 Sluggish,
331}
332
333impl From<ResponseLevelArg> for bhtune_core::ResponseLevel {
334 fn from(value: ResponseLevelArg) -> Self {
335 match value {
336 ResponseLevelArg::Aggressive => bhtune_core::ResponseLevel::Aggressive,
337 ResponseLevelArg::Moderate => bhtune_core::ResponseLevel::Moderate,
338 ResponseLevelArg::Sluggish => bhtune_core::ResponseLevel::Sluggish,
339 }
340 }
341}
342
343impl From<bhtune_core::ResponseLevel> for ResponseLevelArg {
346 fn from(value: bhtune_core::ResponseLevel) -> Self {
347 match value {
348 bhtune_core::ResponseLevel::Aggressive => ResponseLevelArg::Aggressive,
349 bhtune_core::ResponseLevel::Moderate => ResponseLevelArg::Moderate,
350 bhtune_core::ResponseLevel::Sluggish => ResponseLevelArg::Sluggish,
351 }
352 }
353}
354
355#[derive(Args, Debug, Clone)]
357pub struct TuneArgs {
358 #[arg(short = 't', long)]
362 pub tagname: String,
363
364 #[arg(long)]
366 pub template: String,
367
368 #[arg(long, value_enum)]
369 pub process_type: ProcessTypeArg,
370
371 #[arg(long, value_enum)]
372 pub controller_type: ControllerTypeArg,
373
374 #[arg(long, value_parser = finite_f32)]
376 pub relay_amp: f32,
377
378 #[arg(long)]
380 pub cycles_skip: Option<u32>,
381
382 #[arg(long, value_parser = positive_u32)]
385 pub cycles_count: Option<u32>,
386
387 #[arg(long)]
390 pub noise_protection_secs: Option<u32>,
391
392 #[cfg(test)]
395 #[arg(skip)]
396 pub mrft_delay: u32,
397
398 #[arg(long, value_enum)]
400 pub driver: DriverKindArg,
401
402 #[arg(long, env = "BHTUNE_BRIDGE_HOST")]
407 pub bridge_host: Option<String>,
408
409 #[arg(long)]
411 pub server: Option<String>,
412
413 #[arg(long, default_value_t = DEFAULT_SIM_GAIN, value_parser = finite_f32)]
415 pub sim_gain: f32,
416 #[arg(long, default_value_t = DEFAULT_SIM_TAU, value_parser = finite_f32)]
418 pub sim_tau: f32,
419 #[arg(long, default_value_t = DEFAULT_SIM_DEAD_TIME, value_parser = finite_f32)]
421 pub sim_dead_time: f32,
422 #[arg(long, default_value_t = DEFAULT_SIM_NOISE, value_parser = finite_f32)]
424 pub sim_noise: f32,
425 #[arg(long, default_value_t = DEFAULT_SIM_SEED)]
427 pub sim_seed: u64,
428 #[arg(long, default_value_t = DEFAULT_SIM_INITIAL_VALUE, value_parser = finite_f32)]
430 pub sim_initial_pv: f32,
431 #[arg(long, default_value_t = DEFAULT_SIM_INITIAL_VALUE, value_parser = finite_f32)]
433 pub sim_initial_mv: f32,
434
435 #[arg(long, value_parser = finite_f32)]
439 pub pv_range_high: Option<f32>,
440 #[arg(long, value_parser = finite_f32)]
442 pub pv_range_low: Option<f32>,
443 #[arg(long, value_parser = finite_f32)]
445 pub mv_range_high: Option<f32>,
446 #[arg(long, value_parser = finite_f32)]
448 pub mv_range_low: Option<f32>,
449 #[arg(long, value_enum)]
451 pub direction: Option<DirectionArg>,
452
453 #[arg(skip)]
456 pub tag_overrides: Option<TagOverrides>,
457
458 #[cfg(test)]
461 #[arg(skip)]
462 pub poll_interval_ms: u64,
463
464 #[cfg(test)]
467 #[arg(skip)]
468 pub timeout_secs: u64,
469
470 #[arg(long)]
472 pub notes: Option<String>,
473
474 #[arg(long)]
478 pub yes: bool,
479
480 #[arg(long, value_enum)]
484 pub write_pid: Option<ResponseLevelArg>,
485
486 #[cfg(test)]
489 #[arg(skip)]
490 pub op_timeout_secs: u64,
491
492 #[cfg(test)]
495 #[arg(skip)]
496 pub restore_timeout_secs: u64,
497
498 #[arg(long, value_enum, default_value = "table")]
500 pub output: crate::output::OutputFormat,
501}
502
503#[derive(Args, Debug, Clone)]
505pub struct CheckArgs {
506 #[command(flatten)]
507 pub tune: TuneArgs,
508
509 #[arg(long)]
511 pub strict: bool,
512}
513
514impl TryFrom<TuneArgs> for ValidatedTuneRequest {
515 type Error = TuneRequestValidationError;
516
517 fn try_from(args: TuneArgs) -> Result<Self, Self::Error> {
518 let request = TuneRequest {
519 tagname: args.tagname,
520 template: args.template,
521 process_type: args.process_type.into(),
522 controller_type: args.controller_type.into(),
523 relay_amp: args.relay_amp,
524 cycles_skip: args.cycles_skip,
525 cycles_count: args.cycles_count,
526 noise_protection_secs: args.noise_protection_secs,
527 driver: match args.driver {
528 DriverKindArg::Opcda => DriverKind::Opcda,
529 DriverKindArg::Simulator => DriverKind::Simulator,
530 },
531 bridge_host: args.bridge_host,
532 server: args.server,
533 sim_gain: args.sim_gain,
534 sim_tau: args.sim_tau,
535 sim_dead_time: args.sim_dead_time,
536 sim_noise: args.sim_noise,
537 sim_seed: args.sim_seed,
538 sim_initial_pv: args.sim_initial_pv,
539 sim_initial_mv: args.sim_initial_mv,
540 pv_range_high: args.pv_range_high,
541 pv_range_low: args.pv_range_low,
542 mv_range_high: args.mv_range_high,
543 mv_range_low: args.mv_range_low,
544 direction: args.direction.map(Into::into),
545 tag_overrides: args.tag_overrides,
546 notes: args.notes,
547 yes: args.yes,
548 write_pid: args.write_pid.map(Into::into),
549 };
550 ValidatedTuneRequest::try_from(request)
551 }
552}
553
554#[derive(Parser, Debug, Clone)]
556pub struct SimulateArgs {
557 #[arg(short = 't', long, default_value = "Sim.Loop1.PV")]
558 pub tagname: String,
559
560 #[arg(long, default_value = "Yokogawa CentumVP")]
561 pub template: String,
562
563 #[arg(long, value_enum, default_value = "flow")]
564 pub process_type: ProcessTypeArg,
565
566 #[arg(long, value_enum, default_value = "pi")]
567 pub controller_type: ControllerTypeArg,
568
569 #[arg(long, default_value_t = 10.0, value_parser = finite_f32)]
570 pub relay_amp: f32,
571
572 #[arg(long)]
573 pub cycles_skip: Option<u32>,
574 #[arg(long, value_parser = positive_u32)]
575 pub cycles_count: Option<u32>,
576 #[arg(long)]
577 pub noise_protection_secs: Option<u32>,
578 #[cfg(test)]
579 #[arg(skip)]
580 pub mrft_delay: u32,
581
582 #[arg(long, default_value_t = DEFAULT_SIM_GAIN, value_parser = finite_f32)]
583 pub sim_gain: f32,
584 #[arg(long, default_value_t = DEFAULT_SIM_TAU, value_parser = finite_f32)]
585 pub sim_tau: f32,
586 #[arg(long, default_value_t = DEFAULT_SIM_DEAD_TIME, value_parser = finite_f32)]
587 pub sim_dead_time: f32,
588 #[arg(long, default_value_t = DEFAULT_SIM_NOISE, value_parser = finite_f32)]
589 pub sim_noise: f32,
590 #[arg(long, default_value_t = DEFAULT_SIM_SEED)]
591 pub sim_seed: u64,
592 #[arg(long, default_value_t = DEFAULT_SIM_INITIAL_VALUE, value_parser = finite_f32)]
593 pub sim_initial_pv: f32,
594 #[arg(long, default_value_t = DEFAULT_SIM_INITIAL_VALUE, value_parser = finite_f32)]
595 pub sim_initial_mv: f32,
596
597 #[cfg(test)]
598 #[arg(skip)]
599 pub poll_interval_ms: u64,
600
601 #[cfg(test)]
604 #[arg(skip)]
605 pub timeout_secs: u64,
606
607 #[arg(long)]
609 pub notes: Option<String>,
610
611 #[arg(long)]
613 pub yes: bool,
614
615 #[arg(long, value_enum)]
620 pub write_pid: Option<ResponseLevelArg>,
621
622 #[cfg(test)]
625 #[arg(skip)]
626 pub op_timeout_secs: u64,
627
628 #[cfg(test)]
631 #[arg(skip)]
632 pub restore_timeout_secs: u64,
633
634 #[arg(long, value_enum, default_value = "table")]
636 pub output: crate::output::OutputFormat,
637}
638
639impl SimulateArgs {
640 pub fn into_tune_args(self) -> TuneArgs {
643 TuneArgs {
644 tagname: self.tagname,
645 template: self.template,
646 process_type: self.process_type,
647 controller_type: self.controller_type,
648 relay_amp: self.relay_amp,
649 cycles_skip: self.cycles_skip,
650 cycles_count: self.cycles_count,
651 noise_protection_secs: self.noise_protection_secs,
652 driver: DriverKindArg::Simulator,
653 bridge_host: None,
654 server: None,
655 sim_gain: self.sim_gain,
656 sim_tau: self.sim_tau,
657 sim_dead_time: self.sim_dead_time,
658 sim_noise: self.sim_noise,
659 sim_seed: self.sim_seed,
660 sim_initial_pv: self.sim_initial_pv,
661 sim_initial_mv: self.sim_initial_mv,
662 pv_range_high: Some(100.0),
667 pv_range_low: Some(0.0),
668 mv_range_high: Some(100.0),
669 mv_range_low: Some(0.0),
670 direction: Some(DirectionArg::Reverse),
671 tag_overrides: None,
672 notes: self.notes,
673 yes: self.yes,
674 write_pid: self.write_pid,
675 output: self.output,
676 #[cfg(test)]
677 mrft_delay: self.mrft_delay,
678 #[cfg(test)]
679 poll_interval_ms: self.poll_interval_ms,
680 #[cfg(test)]
681 timeout_secs: self.timeout_secs,
682 #[cfg(test)]
683 op_timeout_secs: self.op_timeout_secs,
684 #[cfg(test)]
685 restore_timeout_secs: self.restore_timeout_secs,
686 }
687 }
688}
689
690#[derive(Subcommand, Debug)]
691pub enum TemplateCommand {
692 List,
694 Show { name: String },
696 Import { path: PathBuf },
705 Export {
708 name: String,
709 path: PathBuf,
710 #[arg(long, value_enum, default_value = "json")]
713 format: TemplateFileFormat,
714 },
715 Delete { name: String },
720}
721
722#[derive(ValueEnum, Debug, Clone, Copy, PartialEq, Eq)]
724pub enum TemplateFileFormat {
725 Json,
726 Toml,
727}
728
729#[derive(Subcommand, Debug)]
730pub enum HistoryCommand {
731 List {
733 #[arg(long)]
734 outcome: Option<OutcomeArg>,
735 #[arg(long, default_value_t = 50)]
736 limit: i64,
737 #[arg(long, default_value_t = 0)]
738 offset: i64,
739 #[arg(long, value_enum, default_value = "table")]
741 output: crate::output::OutputFormat,
742 },
743 Show {
746 run_id: i64,
747 #[arg(long, value_enum, default_value = "table")]
749 output: crate::output::OutputFormat,
750 },
751 Revert {
756 run_id: i64,
757 #[arg(long)]
765 bridge_host: Option<String>,
766 #[arg(long)]
771 server: Option<String>,
772 #[arg(long)]
776 yes: bool,
777 #[arg(long, value_enum, default_value = "table")]
779 output: crate::output::OutputFormat,
780 },
781 Prune {
784 #[arg(long, value_parser = positive_u32)]
790 older_than_days: Option<u32>,
791 #[arg(long)]
794 dry_run: bool,
795 #[arg(long, value_enum, default_value = "table")]
797 output: crate::output::OutputFormat,
798 },
799}
800
801impl HistoryCommand {
802 pub(crate) fn output_format(&self) -> crate::output::OutputFormat {
803 match self {
804 HistoryCommand::List { output, .. } => *output,
805 HistoryCommand::Show { output, .. } => *output,
806 HistoryCommand::Revert { output, .. } => *output,
807 HistoryCommand::Prune { output, .. } => *output,
808 }
809 }
810}
811
812#[derive(ValueEnum, Debug, Clone, Copy, PartialEq, Eq)]
814pub enum OutcomeArg {
815 Running,
816 Completed,
817 Failed,
818 Aborted,
819}
820
821impl From<OutcomeArg> for bhtune_db::models::TuneOutcome {
822 fn from(value: OutcomeArg) -> Self {
823 match value {
824 OutcomeArg::Running => bhtune_db::models::TuneOutcome::Running,
825 OutcomeArg::Completed => bhtune_db::models::TuneOutcome::Completed,
826 OutcomeArg::Failed => bhtune_db::models::TuneOutcome::Failed,
827 OutcomeArg::Aborted => bhtune_db::models::TuneOutcome::Aborted,
828 }
829 }
830}
831
832#[derive(Parser, Debug)]
833pub struct ExportArgs {
834 pub run_id: i64,
835 #[arg(long, value_enum, default_value = "csv")]
836 pub format: ExportFormat,
837 #[arg(long)]
839 pub output: Option<PathBuf>,
840}
841
842#[derive(ValueEnum, Debug, Clone, Copy, PartialEq, Eq)]
843pub enum ExportFormat {
844 Csv,
845 Json,
846}
847
848#[derive(Subcommand, Debug)]
849pub enum OpcCommand {
850 GatewayInfo {
852 #[arg(long, env = "BHTUNE_BRIDGE_HOST")]
855 bridge_host: Option<String>,
856 },
857 Servers {
859 #[arg(long, env = "BHTUNE_BRIDGE_HOST")]
862 bridge_host: Option<String>,
863 },
864 Read {
866 #[arg(long, env = "BHTUNE_BRIDGE_HOST")]
869 bridge_host: Option<String>,
870 #[arg(long)]
872 server: Option<String>,
873 tags: Vec<String>,
874 },
875 Write {
877 #[arg(long, env = "BHTUNE_BRIDGE_HOST")]
878 bridge_host: Option<String>,
879 #[arg(long)]
880 server: Option<String>,
881 tag: String,
882 value: String,
883 },
884 Browse {
886 #[arg(long, env = "BHTUNE_BRIDGE_HOST")]
887 bridge_host: Option<String>,
888 #[arg(long)]
889 server: Option<String>,
890 #[arg(long)]
892 session_id: Option<String>,
893 #[arg(long)]
895 parent_node_key: Option<String>,
896 #[arg(long)]
898 page_token: Option<String>,
899 #[arg(long, default_value_t = bhtune_driver::DEFAULT_PAGE_SIZE, value_parser = positive_u32)]
901 page_size: u32,
902 #[arg(long)]
904 all: bool,
905 #[arg(long)]
907 refresh: bool,
908 },
909 Close {
911 #[arg(long, env = "BHTUNE_BRIDGE_HOST")]
912 bridge_host: Option<String>,
913 session_id: String,
915 },
916 Search {
918 #[arg(long, env = "BHTUNE_BRIDGE_HOST")]
919 bridge_host: Option<String>,
920 #[arg(long)]
921 server: Option<String>,
922 query: String,
924 #[arg(long, value_enum, default_value = "contains")]
926 match_mode: OpcSearchMatchModeArg,
927 #[arg(long, default_value_t = bhtune_driver::DEFAULT_SEARCH_MAX_RESULTS, value_parser = positive_u32)]
929 max_results: u32,
930 #[arg(long)]
932 session_id: Option<String>,
933 #[arg(long)]
935 scope_node_key: Option<String>,
936 #[arg(long)]
938 include_branches: bool,
939 #[arg(long)]
941 refresh: bool,
942 },
943 SearchIndex {
945 #[command(subcommand)]
946 command: SearchIndexCommand,
947 },
948}
949
950#[derive(Subcommand, Debug)]
951pub enum SearchIndexCommand {
952 Status {
954 #[arg(long, env = "BHTUNE_BRIDGE_HOST")]
955 bridge_host: Option<String>,
956 #[arg(long)]
957 server: Option<String>,
958 },
959 Search {
961 #[arg(long, env = "BHTUNE_BRIDGE_HOST")]
962 bridge_host: Option<String>,
963 #[arg(long)]
964 server: Option<String>,
965 query: String,
967 #[arg(long, value_enum, default_value = "contains")]
969 match_mode: OpcSearchMatchModeArg,
970 #[arg(long, default_value_t = bhtune_driver::DEFAULT_INDEX_SEARCH_MAX_RESULTS, value_parser = positive_u32)]
972 max_results: u32,
973 },
974 Refresh {
976 #[arg(long, env = "BHTUNE_BRIDGE_HOST")]
977 bridge_host: Option<String>,
978 #[arg(long)]
979 server: Option<String>,
980 #[arg(long)]
982 force: bool,
983 },
984 Control {
986 #[arg(long, env = "BHTUNE_BRIDGE_HOST")]
987 bridge_host: Option<String>,
988 #[arg(long)]
989 server: Option<String>,
990 #[arg(value_enum)]
991 action: SearchIndexControlActionArg,
992 },
993}
994
995#[derive(ValueEnum, Debug, Clone, Copy, PartialEq, Eq)]
996pub enum OpcSearchMatchModeArg {
997 Exact,
998 Prefix,
999 Contains,
1000}
1001
1002#[derive(ValueEnum, Debug, Clone, Copy, PartialEq, Eq)]
1003pub enum SearchIndexControlActionArg {
1004 Pause,
1005 Resume,
1006 Cancel,
1007}
1008
1009impl From<SearchIndexControlActionArg> for bhtune_driver::SearchIndexControlAction {
1010 fn from(value: SearchIndexControlActionArg) -> Self {
1011 match value {
1012 SearchIndexControlActionArg::Pause => bhtune_driver::SearchIndexControlAction::Pause,
1013 SearchIndexControlActionArg::Resume => bhtune_driver::SearchIndexControlAction::Resume,
1014 SearchIndexControlActionArg::Cancel => bhtune_driver::SearchIndexControlAction::Cancel,
1015 }
1016 }
1017}
1018
1019impl From<OpcSearchMatchModeArg> for bhtune_driver::SearchMatchMode {
1020 fn from(value: OpcSearchMatchModeArg) -> Self {
1021 match value {
1022 OpcSearchMatchModeArg::Exact => bhtune_driver::SearchMatchMode::Exact,
1023 OpcSearchMatchModeArg::Prefix => bhtune_driver::SearchMatchMode::Prefix,
1024 OpcSearchMatchModeArg::Contains => bhtune_driver::SearchMatchMode::Contains,
1025 }
1026 }
1027}
1028
1029#[cfg(test)]
1030mod tests {
1031 use super::*;
1032
1033 macro_rules! expect_variant {
1041 ($value:expr, $pattern:pat => $binding:expr, $label:literal) => {
1042 match $value {
1043 $pattern => $binding,
1044 _ => panic!("expected {}", $label),
1045 }
1046 };
1047 }
1048
1049 #[test]
1050 fn expect_variant_panics_on_a_mismatch() {
1051 let command = Cli::parse_from(["bhtune", "simulate"]).command;
1052 let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(
1053 || expect_variant!(command, Command::Tune(a) => a, "Tune"),
1054 ));
1055 let panic_message = *result.unwrap_err().downcast::<&str>().unwrap();
1056 assert_eq!(panic_message, "expected Tune");
1057 }
1058
1059 #[test]
1060 fn search_enum_conversions_cover_every_choice() {
1061 assert_eq!(
1062 bhtune_driver::SearchIndexControlAction::from(SearchIndexControlActionArg::Pause),
1063 bhtune_driver::SearchIndexControlAction::Pause
1064 );
1065 assert_eq!(
1066 bhtune_driver::SearchIndexControlAction::from(SearchIndexControlActionArg::Resume),
1067 bhtune_driver::SearchIndexControlAction::Resume
1068 );
1069 assert_eq!(
1070 bhtune_driver::SearchIndexControlAction::from(SearchIndexControlActionArg::Cancel),
1071 bhtune_driver::SearchIndexControlAction::Cancel
1072 );
1073 assert_eq!(
1074 bhtune_driver::SearchMatchMode::from(OpcSearchMatchModeArg::Prefix),
1075 bhtune_driver::SearchMatchMode::Prefix
1076 );
1077 }
1078
1079 #[test]
1080 fn history_command_output_format_covers_every_variant() {
1081 assert_eq!(
1082 HistoryCommand::List {
1083 outcome: None,
1084 limit: 50,
1085 offset: 0,
1086 output: crate::output::OutputFormat::Json,
1087 }
1088 .output_format(),
1089 crate::output::OutputFormat::Json
1090 );
1091 assert_eq!(
1092 HistoryCommand::Show {
1093 run_id: 1,
1094 output: crate::output::OutputFormat::Json,
1095 }
1096 .output_format(),
1097 crate::output::OutputFormat::Json
1098 );
1099 assert_eq!(
1100 HistoryCommand::Revert {
1101 run_id: 1,
1102 bridge_host: None,
1103 server: None,
1104 yes: false,
1105 output: crate::output::OutputFormat::Json,
1106 }
1107 .output_format(),
1108 crate::output::OutputFormat::Json
1109 );
1110 assert_eq!(
1111 HistoryCommand::Prune {
1112 older_than_days: None,
1113 dry_run: false,
1114 output: crate::output::OutputFormat::Json,
1115 }
1116 .output_format(),
1117 crate::output::OutputFormat::Json
1118 );
1119 }
1120
1121 #[test]
1122 fn command_output_format_forwards_simulate_and_history_formats() {
1123 let simulate = Cli::parse_from(["bhtune", "simulate", "--output", "json"]).command;
1124 assert_eq!(simulate.output_format(), crate::output::OutputFormat::Json);
1125
1126 let history = Cli::parse_from(["bhtune", "history", "list", "--output", "json"]).command;
1127 assert_eq!(history.output_format(), crate::output::OutputFormat::Json);
1128
1129 let restore =
1130 Cli::parse_from(["bhtune", "restore-loop", "12", "--yes", "--output", "json"]).command;
1131 assert_eq!(restore.output_format(), crate::output::OutputFormat::Json);
1132 }
1133
1134 #[test]
1135 fn check_command_accepts_tune_inputs_strict_and_readiness_only_writeback() {
1136 let cli = Cli::parse_from([
1137 "bhtune",
1138 "check",
1139 "--tagname",
1140 "Unit1.LIC101.PV",
1141 "--template",
1142 "Yokogawa CentumVP",
1143 "--process-type",
1144 "flow",
1145 "--controller-type",
1146 "pi",
1147 "--relay-amp",
1148 "10",
1149 "--driver",
1150 "opcda",
1151 "--server",
1152 "Matrikon.OPC.Simulation.1",
1153 "--write-pid",
1154 "moderate",
1155 "--strict",
1156 "--output",
1157 "json",
1158 ]);
1159 assert!(matches!(
1160 cli.command,
1161 Command::Check(args)
1162 if args.strict
1163 && args.tune.write_pid == Some(ResponseLevelArg::Moderate)
1164 && !args.tune.yes
1165 && args.tune.output == crate::output::OutputFormat::Json
1166 ));
1167 }
1168
1169 #[test]
1170 fn finite_f32_accepts_finite_values_and_rejects_invalid_values() {
1171 assert_eq!(finite_f32("2.5").unwrap(), 2.5);
1172 assert!(finite_f32("not-a-number").is_err());
1173 assert!(finite_f32("nan").is_err());
1174 assert!(finite_f32("inf").is_err());
1175 }
1176
1177 #[test]
1178 fn positive_integer_parsers_reject_non_numeric_input() {
1179 assert_eq!(
1180 positive_u32("not-a-number").unwrap_err(),
1181 "'not-a-number' is not a valid non-negative integer"
1182 );
1183 assert_eq!(positive_u32("0").unwrap_err(), "must be at least 1");
1184 }
1185
1186 #[test]
1187 fn process_type_arg_converts_to_every_core_variant() {
1188 assert_eq!(
1189 bhtune_core::ProcessType::from(ProcessTypeArg::Flow),
1190 bhtune_core::ProcessType::Flow
1191 );
1192 assert_eq!(
1193 bhtune_core::ProcessType::from(ProcessTypeArg::PressureLine),
1194 bhtune_core::ProcessType::PressureLine
1195 );
1196 assert_eq!(
1197 bhtune_core::ProcessType::from(ProcessTypeArg::PressureVessel),
1198 bhtune_core::ProcessType::PressureVessel
1199 );
1200 assert_eq!(
1201 bhtune_core::ProcessType::from(ProcessTypeArg::Level),
1202 bhtune_core::ProcessType::Level
1203 );
1204 assert_eq!(
1205 bhtune_core::ProcessType::from(ProcessTypeArg::TemperatureMixing),
1206 bhtune_core::ProcessType::TemperatureMixing
1207 );
1208 assert_eq!(
1209 bhtune_core::ProcessType::from(ProcessTypeArg::TemperatureHeatExchange),
1210 bhtune_core::ProcessType::TemperatureHeatExchange
1211 );
1212 }
1213
1214 #[test]
1215 fn controller_type_arg_converts_to_every_core_variant() {
1216 assert_eq!(
1217 bhtune_core::ControllerType::from(ControllerTypeArg::P),
1218 bhtune_core::ControllerType::P
1219 );
1220 assert_eq!(
1221 bhtune_core::ControllerType::from(ControllerTypeArg::Pi),
1222 bhtune_core::ControllerType::Pi
1223 );
1224 assert_eq!(
1225 bhtune_core::ControllerType::from(ControllerTypeArg::Pid),
1226 bhtune_core::ControllerType::Pid
1227 );
1228 }
1229
1230 #[test]
1231 fn direction_arg_converts_to_every_core_variant() {
1232 assert_eq!(
1233 bhtune_core::ControllerDirection::from(DirectionArg::Direct),
1234 bhtune_core::ControllerDirection::Direct
1235 );
1236 assert_eq!(
1237 bhtune_core::ControllerDirection::from(DirectionArg::Reverse),
1238 bhtune_core::ControllerDirection::Reverse
1239 );
1240 }
1241
1242 #[test]
1243 fn process_type_converts_back_to_every_arg_variant() {
1244 assert_eq!(
1245 ProcessTypeArg::from(bhtune_core::ProcessType::Flow),
1246 ProcessTypeArg::Flow
1247 );
1248 assert_eq!(
1249 ProcessTypeArg::from(bhtune_core::ProcessType::PressureLine),
1250 ProcessTypeArg::PressureLine
1251 );
1252 assert_eq!(
1253 ProcessTypeArg::from(bhtune_core::ProcessType::PressureVessel),
1254 ProcessTypeArg::PressureVessel
1255 );
1256 assert_eq!(
1257 ProcessTypeArg::from(bhtune_core::ProcessType::Level),
1258 ProcessTypeArg::Level
1259 );
1260 assert_eq!(
1261 ProcessTypeArg::from(bhtune_core::ProcessType::TemperatureMixing),
1262 ProcessTypeArg::TemperatureMixing
1263 );
1264 assert_eq!(
1265 ProcessTypeArg::from(bhtune_core::ProcessType::TemperatureHeatExchange),
1266 ProcessTypeArg::TemperatureHeatExchange
1267 );
1268 }
1269
1270 #[test]
1271 fn controller_type_converts_back_to_every_arg_variant() {
1272 assert_eq!(
1273 ControllerTypeArg::from(bhtune_core::ControllerType::P),
1274 ControllerTypeArg::P
1275 );
1276 assert_eq!(
1277 ControllerTypeArg::from(bhtune_core::ControllerType::Pi),
1278 ControllerTypeArg::Pi
1279 );
1280 assert_eq!(
1281 ControllerTypeArg::from(bhtune_core::ControllerType::Pid),
1282 ControllerTypeArg::Pid
1283 );
1284 }
1285
1286 #[test]
1287 fn direction_converts_back_to_every_arg_variant() {
1288 assert_eq!(
1289 DirectionArg::from(bhtune_core::ControllerDirection::Direct),
1290 DirectionArg::Direct
1291 );
1292 assert_eq!(
1293 DirectionArg::from(bhtune_core::ControllerDirection::Reverse),
1294 DirectionArg::Reverse
1295 );
1296 }
1297
1298 #[test]
1299 fn driver_kind_arg_try_from_tune_driver_covers_the_implemented_drivers() {
1300 assert_eq!(
1301 DriverKindArg::try_from(bhtune_db::models::TuneDriver::Opcda).unwrap(),
1302 DriverKindArg::Opcda
1303 );
1304 assert_eq!(
1305 DriverKindArg::try_from(bhtune_db::models::TuneDriver::Simulator).unwrap(),
1306 DriverKindArg::Simulator
1307 );
1308 }
1309
1310 #[test]
1311 fn driver_kind_arg_try_from_tune_driver_rejects_replay() {
1312 let err = DriverKindArg::try_from(bhtune_db::models::TuneDriver::Replay).unwrap_err();
1313 assert_eq!(err, ReplayDriverUnsupported);
1314 assert!(err.to_string().contains("replay"));
1315 }
1316
1317 #[test]
1318 fn outcome_arg_converts_to_every_db_variant() {
1319 assert_eq!(
1320 bhtune_db::models::TuneOutcome::from(OutcomeArg::Running),
1321 bhtune_db::models::TuneOutcome::Running
1322 );
1323 assert_eq!(
1324 bhtune_db::models::TuneOutcome::from(OutcomeArg::Completed),
1325 bhtune_db::models::TuneOutcome::Completed
1326 );
1327 assert_eq!(
1328 bhtune_db::models::TuneOutcome::from(OutcomeArg::Failed),
1329 bhtune_db::models::TuneOutcome::Failed
1330 );
1331 assert_eq!(
1332 bhtune_db::models::TuneOutcome::from(OutcomeArg::Aborted),
1333 bhtune_db::models::TuneOutcome::Aborted
1334 );
1335 }
1336
1337 #[test]
1338 fn simulate_args_expand_into_tune_args_with_simulator_driver() {
1339 let cli = Cli::parse_from(["bhtune", "simulate"]);
1340 let simulate = expect_variant!(cli.command, Command::Simulate(s) => s, "Simulate");
1341 let tune = simulate.into_tune_args();
1342 assert!(matches!(tune.driver, DriverKindArg::Simulator));
1343 assert_eq!(tune.tagname, "Sim.Loop1.PV");
1344 assert_eq!(tune.pv_range_low, Some(0.0));
1345 assert_eq!(tune.pv_range_high, Some(100.0));
1346 assert_eq!(tune.mv_range_low, Some(0.0));
1347 assert_eq!(tune.mv_range_high, Some(100.0));
1348 assert!(matches!(tune.direction, Some(DirectionArg::Reverse)));
1349 assert!(!tune.yes);
1350 assert!(tune.write_pid.is_none());
1351 assert_eq!(tune.output, crate::output::OutputFormat::Table);
1352 }
1353
1354 #[test]
1355 fn simulate_args_expand_into_tune_args_carries_yes_write_pid_and_output_through() {
1356 let cli = Cli::parse_from([
1357 "bhtune",
1358 "simulate",
1359 "--yes",
1360 "--write-pid",
1361 "sluggish",
1362 "--output",
1363 "json",
1364 ]);
1365 let simulate = expect_variant!(cli.command, Command::Simulate(s) => s, "Simulate");
1366 let tune = simulate.into_tune_args();
1367 assert!(tune.yes);
1368 assert!(matches!(tune.write_pid, Some(ResponseLevelArg::Sluggish)));
1369 assert_eq!(tune.output, crate::output::OutputFormat::Json);
1370 }
1371
1372 #[test]
1373 fn response_level_arg_converts_to_every_core_variant() {
1374 assert_eq!(
1375 bhtune_core::ResponseLevel::from(ResponseLevelArg::Aggressive),
1376 bhtune_core::ResponseLevel::Aggressive
1377 );
1378 assert_eq!(
1379 bhtune_core::ResponseLevel::from(ResponseLevelArg::Moderate),
1380 bhtune_core::ResponseLevel::Moderate
1381 );
1382 assert_eq!(
1383 bhtune_core::ResponseLevel::from(ResponseLevelArg::Sluggish),
1384 bhtune_core::ResponseLevel::Sluggish
1385 );
1386 }
1387
1388 #[test]
1389 fn response_level_converts_back_to_every_arg_variant() {
1390 assert_eq!(
1391 ResponseLevelArg::from(bhtune_core::ResponseLevel::Aggressive),
1392 ResponseLevelArg::Aggressive
1393 );
1394 assert_eq!(
1395 ResponseLevelArg::from(bhtune_core::ResponseLevel::Moderate),
1396 ResponseLevelArg::Moderate
1397 );
1398 assert_eq!(
1399 ResponseLevelArg::from(bhtune_core::ResponseLevel::Sluggish),
1400 ResponseLevelArg::Sluggish
1401 );
1402 }
1403
1404 #[test]
1405 fn cli_parses_a_full_tune_command() {
1406 let cli = Cli::parse_from([
1407 "bhtune",
1408 "tune",
1409 "-t",
1410 "Unit1.LIC101.PV",
1411 "--template",
1412 "Yokogawa CentumVP",
1413 "--process-type",
1414 "flow",
1415 "--controller-type",
1416 "pi",
1417 "--relay-amp",
1418 "5.0",
1419 "--driver",
1420 "simulator",
1421 ]);
1422 let args = expect_variant!(cli.command, Command::Tune(a) => a, "Tune");
1423 assert_eq!(args.tagname, "Unit1.LIC101.PV");
1424 assert!(matches!(args.process_type, ProcessTypeArg::Flow));
1425 assert!(matches!(args.controller_type, ControllerTypeArg::Pi));
1426 assert!(matches!(args.driver, DriverKindArg::Simulator));
1427 assert!(!args.yes);
1428 assert!(args.write_pid.is_none());
1429 assert_eq!(args.output, crate::output::OutputFormat::Table);
1430 }
1431
1432 #[test]
1433 fn cli_parses_tune_yes_and_write_pid_flags() {
1434 let cli = Cli::parse_from([
1435 "bhtune",
1436 "tune",
1437 "-t",
1438 "Unit1.LIC101.PV",
1439 "--template",
1440 "Yokogawa CentumVP",
1441 "--process-type",
1442 "flow",
1443 "--controller-type",
1444 "pi",
1445 "--relay-amp",
1446 "5.0",
1447 "--driver",
1448 "simulator",
1449 "--yes",
1450 "--write-pid",
1451 "moderate",
1452 "--output",
1453 "json",
1454 ]);
1455 let args = expect_variant!(cli.command, Command::Tune(a) => a, "Tune");
1456 assert!(args.yes);
1457 assert!(matches!(args.write_pid, Some(ResponseLevelArg::Moderate)));
1458 assert_eq!(args.output, crate::output::OutputFormat::Json);
1459 }
1460
1461 #[test]
1462 fn cli_rejects_per_run_timing_flags() {
1463 for flag in [
1464 "--mrft-delay",
1465 "--poll-interval-ms",
1466 "--timeout-secs",
1467 "--op-timeout-secs",
1468 "--restore-timeout-secs",
1469 ] {
1470 let result = Cli::try_parse_from(["bhtune", "simulate", flag, "1"]);
1471 assert!(result.is_err(), "{flag} must remain global-config-only");
1472 }
1473 }
1474
1475 #[test]
1476 fn cli_parses_history_list_with_filters() {
1477 let cli = Cli::parse_from([
1478 "bhtune",
1479 "history",
1480 "list",
1481 "--outcome",
1482 "completed",
1483 "--limit",
1484 "10",
1485 ]);
1486 let command =
1487 expect_variant!(cli.command, Command::History { command } => command, "History");
1488 let (outcome, limit, offset, output) = expect_variant!(
1489 command,
1490 HistoryCommand::List { outcome, limit, offset, output } => (outcome, limit, offset, output),
1491 "List"
1492 );
1493 assert!(matches!(outcome, Some(OutcomeArg::Completed)));
1494 assert_eq!(limit, 10);
1495 assert_eq!(offset, 0);
1496 assert_eq!(output, crate::output::OutputFormat::Table);
1497 }
1498
1499 #[test]
1500 fn cli_parses_history_list_and_show_with_output_json() {
1501 let cli = Cli::parse_from(["bhtune", "history", "list", "--output", "json"]);
1502 let command =
1503 expect_variant!(cli.command, Command::History { command } => command, "History");
1504 assert_eq!(command.output_format(), crate::output::OutputFormat::Json);
1505
1506 let cli = Cli::parse_from(["bhtune", "history", "show", "42", "--output", "json"]);
1507 let command =
1508 expect_variant!(cli.command, Command::History { command } => command, "History");
1509 let (run_id, output) = expect_variant!(
1510 command,
1511 HistoryCommand::Show { run_id, output } => (run_id, output),
1512 "Show"
1513 );
1514 assert_eq!(run_id, 42);
1515 assert_eq!(output, crate::output::OutputFormat::Json);
1516 }
1517
1518 #[test]
1519 fn cli_parses_history_prune_defaults() {
1520 let cli = Cli::parse_from(["bhtune", "history", "prune"]);
1521 let command =
1522 expect_variant!(cli.command, Command::History { command } => command, "History");
1523 let (older_than_days, dry_run, output) = expect_variant!(
1524 command,
1525 HistoryCommand::Prune { older_than_days, dry_run, output } => (older_than_days, dry_run, output),
1526 "Prune"
1527 );
1528 assert_eq!(older_than_days, None);
1529 assert!(!dry_run);
1530 assert_eq!(output, crate::output::OutputFormat::Table);
1531 }
1532
1533 #[test]
1534 fn cli_parses_history_prune_with_older_than_days_and_dry_run() {
1535 let cli = Cli::parse_from([
1536 "bhtune",
1537 "history",
1538 "prune",
1539 "--older-than-days",
1540 "14",
1541 "--dry-run",
1542 "--output",
1543 "json",
1544 ]);
1545 let command =
1546 expect_variant!(cli.command, Command::History { command } => command, "History");
1547 let (older_than_days, dry_run, output) = expect_variant!(
1548 command,
1549 HistoryCommand::Prune { older_than_days, dry_run, output } => (older_than_days, dry_run, output),
1550 "Prune"
1551 );
1552 assert_eq!(older_than_days, Some(14));
1553 assert!(dry_run);
1554 assert_eq!(output, crate::output::OutputFormat::Json);
1555 }
1556
1557 #[test]
1558 fn cli_rejects_a_zero_history_prune_older_than_days() {
1559 let result = Cli::try_parse_from(["bhtune", "history", "prune", "--older-than-days", "0"]);
1560 assert!(result.is_err());
1561 }
1562
1563 #[test]
1564 fn command_output_format_defaults_to_table_for_commands_without_the_concept() {
1565 let cli = Cli::parse_from(["bhtune", "template", "list"]);
1566 assert_eq!(
1567 cli.command.output_format(),
1568 crate::output::OutputFormat::Table
1569 );
1570
1571 let cli = Cli::parse_from(["bhtune", "opc", "read", "Unit1.LIC101.PV"]);
1572 assert_eq!(
1573 cli.command.output_format(),
1574 crate::output::OutputFormat::Table
1575 );
1576
1577 let cli = Cli::parse_from(["bhtune", "export", "1"]);
1578 assert_eq!(
1579 cli.command.output_format(),
1580 crate::output::OutputFormat::Table
1581 );
1582
1583 let cli = Cli::parse_from(["bhtune", "simulate", "--output", "json"]);
1584 assert_eq!(
1585 cli.command.output_format(),
1586 crate::output::OutputFormat::Json
1587 );
1588 }
1589
1590 #[test]
1591 fn cli_rejects_missing_required_tune_flags() {
1592 let result = Cli::try_parse_from(["bhtune", "tune"]);
1593 assert!(result.is_err());
1594 }
1595
1596 #[test]
1597 fn cli_db_config_and_templates_default_to_none() {
1598 let cli = Cli::parse_from(["bhtune", "simulate"]);
1599 assert_eq!(cli.db, None);
1600 assert_eq!(cli.config, None);
1601 assert_eq!(cli.templates, None);
1602 assert_eq!(cli.retention_days, None);
1603 }
1604
1605 #[test]
1606 fn cli_parses_explicit_db_config_and_templates_flags() {
1607 let cli = Cli::parse_from([
1608 "bhtune",
1609 "--db",
1610 "/data/bhtune.db",
1611 "--config",
1612 "/etc/bhtune.toml",
1613 "--templates",
1614 "/etc/bhtune/templates.toml",
1615 "--retention-days",
1616 "30",
1617 "simulate",
1618 ]);
1619 assert_eq!(cli.db, Some(PathBuf::from("/data/bhtune.db")));
1620 assert_eq!(cli.config, Some(PathBuf::from("/etc/bhtune.toml")));
1621 assert_eq!(
1622 cli.templates,
1623 Some(PathBuf::from("/etc/bhtune/templates.toml"))
1624 );
1625 assert_eq!(cli.retention_days, Some(30));
1626 }
1627
1628 #[test]
1629 fn cli_rejects_a_zero_retention_days() {
1630 let result = Cli::try_parse_from(["bhtune", "--retention-days", "0", "simulate"]);
1633 assert!(result.is_err());
1634 }
1635
1636 #[test]
1637 fn cli_log_flags_default_to_none() {
1638 let cli = Cli::parse_from(["bhtune", "simulate"]);
1639 assert_eq!(cli.log_level, None);
1640 assert_eq!(cli.log_dir, None);
1641 assert_eq!(cli.log_format, None);
1642 assert_eq!(cli.log_rotation, None);
1643 }
1644
1645 #[test]
1646 fn cli_parses_explicit_log_flags() {
1647 let cli = Cli::parse_from([
1648 "bhtune",
1649 "--log-level",
1650 "debug",
1651 "--log-dir",
1652 "/var/log/bhtune",
1653 "--log-format",
1654 "json",
1655 "--log-rotation",
1656 "hourly",
1657 "simulate",
1658 ]);
1659 assert_eq!(cli.log_level, Some("debug".to_string()));
1660 assert_eq!(cli.log_dir, Some(PathBuf::from("/var/log/bhtune")));
1661 assert_eq!(cli.log_format, Some("json".to_string()));
1662 assert_eq!(cli.log_rotation, Some("hourly".to_string()));
1663 }
1664
1665 #[test]
1666 fn tune_args_bridge_host_defaults_to_none() {
1667 let cli = Cli::parse_from([
1668 "bhtune",
1669 "tune",
1670 "-t",
1671 "Unit1.LIC101.PV",
1672 "--template",
1673 "Yokogawa CentumVP",
1674 "--process-type",
1675 "flow",
1676 "--controller-type",
1677 "pi",
1678 "--relay-amp",
1679 "5.0",
1680 "--driver",
1681 "simulator",
1682 ]);
1683 let args = expect_variant!(cli.command, Command::Tune(a) => a, "Tune");
1684 assert_eq!(args.bridge_host, None);
1685 }
1686
1687 #[test]
1688 fn opc_servers_bridge_host_defaults_to_none() {
1689 let cli = Cli::parse_from(["bhtune", "opc", "servers"]);
1690 let command = expect_variant!(cli.command, Command::Opc { command, .. } => command, "Opc");
1691 let bridge_host =
1692 expect_variant!(command, OpcCommand::Servers { bridge_host } => bridge_host, "Servers");
1693 assert_eq!(bridge_host, None);
1694 }
1695
1696 #[test]
1697 fn opc_gateway_info_bridge_host_defaults_to_none() {
1698 let cli = Cli::parse_from(["bhtune", "opc", "gateway-info"]);
1699 let command = expect_variant!(cli.command, Command::Opc { command, .. } => command, "Opc");
1700 let bridge_host = expect_variant!(
1701 command,
1702 OpcCommand::GatewayInfo { bridge_host } => bridge_host,
1703 "GatewayInfo"
1704 );
1705 assert_eq!(bridge_host, None);
1706 }
1707
1708 #[test]
1709 fn opc_read_bridge_host_and_server_default_to_none() {
1710 let cli = Cli::parse_from(["bhtune", "opc", "read", "Unit1.LIC101.PV"]);
1711 let command = expect_variant!(cli.command, Command::Opc { command, .. } => command, "Opc");
1712 let (bridge_host, server) = expect_variant!(
1713 command,
1714 OpcCommand::Read { bridge_host, server, .. } => (bridge_host, server),
1715 "Read"
1716 );
1717 assert_eq!(bridge_host, None);
1718 assert_eq!(server, None);
1719 }
1720}