bhtune_cli/lib.rs
1//! `bhtune-cli` — the headless adapter.
2//!
3//! Builds the `bhtune` binary: a scriptable, no-GUI way to run an MRFT tune and inspect its
4//! history, intended for scheduled/unattended use (cron, CI, batch tuning campaigns) as well
5//! as interactive terminal use.
6//!
7//! - [`args`] — the `clap` derive `Cli`/`Command` definitions and the wrapper enums adapting
8//! `bhtune-core`'s domain enums to `clap::ValueEnum` (required by Rust's orphan rule).
9//! - [`config`] — `CLI > env > TOML config file > platform default` precedence for the
10//! database path, opcda-bridge gateway address, default OPC server, and the user-supplied
11//! template catalog path (`template-user-catalog`).
12//! - [`db`] — opens the database and seeds the built-in and (if configured) user-catalog
13//! DCS/PLC templates on every startup, and runs the `history-retention` sweep if a policy
14//! is configured.
15//! - [`retention`] — turns `history-retention`'s "N days" policy into a cutoff and a
16//! logged deletion sweep, shared by [`db::open`]'s startup call, `bhtune-server`'s
17//! periodic timer, and `bhtune history prune`.
18//! - [`driver`] — constructs the selected `Driver` implementation.
19//! - `timing` — supplies live or fixed-step timestamps to the clock-free MRFT engine.
20//! - [`commands`] — one module per subcommand family: `tune`/`simulate`, `template`,
21//! `history`, `export`, `opc`.
22//! - [`output`] — the `--output table|json` format shared by `history list`/`history show`
23//! and `tune`/`simulate`'s final summary, plus error formatting.
24//! - [`logging`] — `tracing`/`tracing-subscriber` structured logging (`cli-logging`),
25//! initialized once in [`run`], never touching stdout so it can never interleave with
26//! `--output json`'s single-object contract.
27//!
28//! `main.rs` stays a one-line delegator to [`run`]; [`run_with_cli`] is the actual entry
29//! point, kept separate so tests can exercise it against an already-parsed [`args::Cli`]
30//! without needing to control `std::env::args()` — mirroring `opcda-bridge-client`'s
31//! `run`/`run_with_cli` split. Logging is initialized in [`run`], not [`run_with_cli`], for
32//! the same reason: it keeps tracing setup (and its process-global, only-succeeds-once
33//! subscriber installation) entirely out of `run_with_cli`'s own large, injection-based test
34//! suite -- see `logging`'s test module doc comment.
35//!
36//! Non-interactive/scheduled use (cron, CI, batch campaigns) is `tune`/`simulate`'s
37//! `--yes`/`--write-pid <level>` flags (bypassing the interactive write-back prompt) plus
38//! the global `[tuning]` timing configuration, which mandatorily bounds unattended runs and
39//! caps individual driver operations, and this module's distinguished exit codes
40//! ([`EXIT_ABORTED`], [`EXIT_TIMED_OUT`],
41//! [`EXIT_POOR_QUALITY`], [`EXIT_ACTUATION_FAILED`], [`EXIT_WRITE_BACK_FAILED`],
42//! [`EXIT_RESTORE_INCOMPLETE`]), so a
43//! scheduler can tell "aborted", "timed out", "the plant data couldn't be trusted", "test ran
44//! but the write-back failed", "the loop may not have been fully restored", and "never ran at
45//! all" apart without parsing stdout. See AGENTS.md's `cli-automation`/`cli-safety` sections.
46
47pub mod args;
48pub mod cancel;
49pub mod commands;
50pub mod config;
51pub mod db;
52pub mod driver;
53pub mod logging;
54pub mod output;
55pub mod retention;
56#[cfg(test)]
57mod test_support;
58mod timing;
59
60use std::process::ExitCode;
61
62use clap::Parser;
63
64use args::{Cli, Command};
65use output::OutputFormat;
66
67/// Process exited normally, and if this was `tune`/`simulate`, any PID write-back either
68/// succeeded or was cleanly skipped. Equal to [`ExitCode::SUCCESS`].
69pub const EXIT_SUCCESS: u8 = 0;
70/// A setup problem (bad flags, an unreadable config file, a database error, an unexpected
71/// driver error) prevented the command from running to completion at all. Equal to
72/// [`ExitCode::FAILURE`].
73pub const EXIT_FAILURE: u8 = 1;
74/// A `tune`/`simulate` run was aborted (Ctrl+C) before it finished; the loop was restored to
75/// its pre-test mode. Distinct from [`EXIT_FAILURE`] so a scheduler can tell "someone
76/// intentionally stopped this" apart from "this broke".
77pub const EXIT_ABORTED: u8 = 2;
78/// A `tune`/`simulate` run completed the MRFT test itself, but writing the selected PID
79/// constants back to the DCS failed (the write was rejected, errored, or its confirmation
80/// readback didn't match). Distinct from both [`EXIT_SUCCESS`] (nothing to report) and
81/// [`EXIT_FAILURE`] (the test itself never produced a result) so an unattended
82/// `--write-pid`/`--yes` run can tell "the test ran fine but the loop was NOT updated" apart
83/// from either of those. See `commands::tune::TuneOutcome` and AGENTS.md's `cli-automation`
84/// section.
85pub const EXIT_WRITE_BACK_FAILED: u8 = 3;
86/// A `tune`/`simulate` run was aborted because `[tuning].timeout_secs` elapsed before the
87/// engine reported completion; the loop was restored to its pre-test mode, exactly like
88/// [`EXIT_ABORTED`]. Distinct from it so a scheduler's alerting can tell "this run had to be
89/// killed for running too long" (possibly a stuck relay, a misconfigured tag mapping, or a
90/// stalled driver read -- worth investigating) apart from "an operator stopped it on
91/// purpose" (routine). See `commands::tune::TuneOutcome::TimedOut` and AGENTS.md's
92/// `cli-safety` section.
93pub const EXIT_TIMED_OUT: u8 = 4;
94/// A `tune`/`simulate` run was aborted because a driver reported a non-`Good` OPC quality
95/// for a tuning-critical reading (finding 5 of the live-plant safety review): an initial
96/// reading, the transition-to-manual setpoint capture, or an in-flight PV poll sample, and
97/// (for the in-flight case) the global Config > OPC quality policy rejected `Uncertain`, or
98/// the quality was `Bad` rather than merely `Uncertain`. The loop was restored to its pre-test
99/// mode, exactly like [`EXIT_ABORTED`]/[`EXIT_TIMED_OUT`]. Distinct from both so a scheduler's alerting can
100/// tell "the plant data itself couldn't be trusted" apart from a user-initiated stop or a
101/// run that simply took too long. See `commands::tune::TuneOutcome::PoorQuality` and
102/// AGENTS.md's `safety-quality` section.
103pub const EXIT_POOR_QUALITY: u8 = 5;
104/// A `tune`/`simulate` run ended (via normal completion, Ctrl+C, or a timeout) without being
105/// able to confirm the loop was fully restored to its pre-test mode/MV/setpoint -- either a
106/// second Ctrl+C was received while the restore was in flight, or
107/// `[tuning].restore_timeout_secs` elapsed first. Distinct from every other exit code because
108/// it means the loop may have
109/// been left mutated with no further attempt made to fix it: an operator must check it by
110/// hand, using the tag/value named in the warning printed to stderr. See
111/// `commands::tune::TuneOutcome::RestoreIncomplete` and AGENTS.md's `safety-cancellation`
112/// section.
113pub const EXIT_RESTORE_INCOMPLETE: u8 = 6;
114/// A live OPC DA tune was aborted because an accepted MV command could not be confirmed at
115/// the controller before its deadline or before a replacement relay command was required.
116/// The ordinary restore path still ran; [`EXIT_RESTORE_INCOMPLETE`] takes precedence if that
117/// restore could not itself be confirmed.
118pub const EXIT_ACTUATION_FAILED: u8 = 7;
119
120/// Parses real CLI arguments, initializes structured logging, and runs, returning a process
121/// exit code.
122///
123/// Logging is resolved and initialized here rather than in [`run_with_cli`] -- see the crate
124/// doc comment. It reads the config file once, purely for `[log]` settings; `run_with_cli`
125/// reads it again moments later for the database path and other settings. That small,
126/// one-time duplication keeps `run_with_cli`'s own extensively unit-tested call path (which
127/// never touches a real log directory) fully decoupled from tracing setup, which can only
128/// ever be installed once per process. A logging setup failure (e.g. an unwritable log
129/// directory) is deliberately non-fatal -- see [`logging::init_tracing`] -- so it never
130/// prevents the actual command (and its `println!`-based result) from running.
131///
132/// [`cancel::CtrlC::install`] is called here, as the very first line, rather than inside
133/// [`run_with_cli`] or anywhere later -- deliberately earlier than the Ctrl+C listener's
134/// strict minimum requirement (registered once before the polling loop starts), so that a
135/// Ctrl+C pressed during config loading, logging setup, database open/migrate/seed, or the
136/// initial-readings/mode-transition sequence is also captured rather than lost or hitting
137/// the OS default (process kill, skipping the loop restore entirely). See
138/// `safety-cancellation` in AGENTS.md.
139pub async fn run() -> ExitCode {
140 let ctrl_c = cancel::CtrlC::install();
141 let cli = Cli::parse();
142
143 let output_format = cli.command.output_format();
144 let config = match load_startup_config(cli.config.as_deref(), output_format) {
145 Ok(config) => config,
146 Err(code) => return code,
147 };
148 let default_log_dir = config::default_log_dir_from(
149 std::env::var("XDG_DATA_HOME").ok().as_deref(),
150 std::env::var("HOME").ok().as_deref(),
151 std::env::var("APPDATA").ok().as_deref(),
152 cfg!(target_os = "windows"),
153 );
154 let log_settings = logging::resolve_log_settings(
155 cli.log_level.clone(),
156 cli.log_dir.clone(),
157 cli.log_format.clone(),
158 cli.log_rotation.clone(),
159 &config.log,
160 &default_log_dir,
161 );
162 // Held for the rest of this function's scope, which is the whole remaining lifetime of
163 // the process (`main.rs` immediately returns whatever `ExitCode` this call resolves to)
164 // -- dropping it any earlier would risk silently truncating buffered log lines.
165 let _log_guard = logging::init_tracing(&log_settings);
166
167 run_with_cli_and_ctrl_c(cli, ctrl_c).await
168}
169
170fn load_startup_config(
171 path: Option<&std::path::Path>,
172 output: OutputFormat,
173) -> Result<config::BhtuneConfig, ExitCode> {
174 config::load_config(path).map_err(|error| fail(&error, output))
175}
176
177/// Test-facing entry point: exercises [`run_with_cli_and_ctrl_c`] against an already-parsed
178/// [`args::Cli`] with a [`cancel::CtrlC::never`] handle, so the large existing test suite
179/// built around this function never installs a real process-wide signal handler -- see
180/// `cancel`'s module doc comment for why that matters beyond just this crate's own tests.
181/// Real process startup ([`run`]) calls [`run_with_cli_and_ctrl_c`] directly with a real,
182/// installed [`cancel::CtrlC`] instead of going through this wrapper. `#[cfg(test)]`-gated
183/// (rather than merely unused outside tests) because it depends on [`cancel::CtrlC::never`],
184/// itself only defined for test builds -- see that function's own doc comment.
185#[cfg(test)]
186pub(crate) async fn run_with_cli(cli: Cli) -> ExitCode {
187 run_with_cli_and_ctrl_c(cli, cancel::CtrlC::never()).await
188}
189
190/// Loads the config file, resolves the database path, dispatches to the requested
191/// subcommand, and reports any error.
192///
193/// Config loading and DB-path resolution happen here (not inside `db::open` or each
194/// `commands::*::run`) because this is the one call site that has access to real process
195/// environment variables (`XDG_DATA_HOME`/`HOME`/`APPDATA`) -- everything downstream of this
196/// function takes already-resolved values or the loaded [`config::BhtuneConfig`] itself,
197/// keeping the config-precedence logic in `config.rs` fully unit-testable by injection.
198async fn run_with_cli_and_ctrl_c(cli: Cli, mut ctrl_c: cancel::CtrlC) -> ExitCode {
199 // Captured before `cli.command` is moved into the dispatch match below, so a config/db
200 // error can still be reported in the format the command actually asked for.
201 let output_format = cli.command.output_format();
202
203 let config = match config::load_config(cli.config.as_deref()) {
204 Ok(config) => config,
205 Err(e) => return fail(&e, output_format),
206 };
207 let db_path = config::resolve_db_path(
208 cli.db,
209 &config,
210 std::env::var("XDG_DATA_HOME").ok().as_deref(),
211 std::env::var("HOME").ok().as_deref(),
212 std::env::var("APPDATA").ok().as_deref(),
213 cfg!(target_os = "windows"),
214 );
215 let user_templates = match config::load_user_templates(
216 cli.templates,
217 &config,
218 std::env::var("XDG_CONFIG_HOME").ok().as_deref(),
219 std::env::var("HOME").ok().as_deref(),
220 std::env::var("APPDATA").ok().as_deref(),
221 cfg!(target_os = "windows"),
222 ) {
223 Ok(templates) => templates,
224 Err(e) => return fail(&e, output_format),
225 };
226 let retention_days = config::resolve_retention_days(cli.retention_days, &config);
227
228 match db::open(&db_path, user_templates, retention_days).await {
229 Err(e) => fail(&e, output_format),
230 Ok(pool) => {
231 let result: anyhow::Result<ExitCode> = match cli.command {
232 Command::Tune(args) => {
233 commands::tune::run_with_ctrl_c(&pool, args, &config, &mut ctrl_c)
234 .await
235 .map(tune_outcome_exit_code)
236 }
237 Command::Simulate(args) => commands::tune::run_with_ctrl_c(
238 &pool,
239 args.into_tune_args(),
240 &config,
241 &mut ctrl_c,
242 )
243 .await
244 .map(tune_outcome_exit_code),
245 Command::Template { command } => commands::template::run(&pool, command)
246 .await
247 .map(|()| ExitCode::SUCCESS),
248 Command::History { command } => commands::history::run(&pool, command, &config)
249 .await
250 .map(|()| ExitCode::SUCCESS),
251 Command::Export(args) => commands::export::run(&pool, args)
252 .await
253 .map(|()| ExitCode::SUCCESS),
254 Command::Opc { output, command } => {
255 commands::opc::run_with_output(command, &config, output)
256 .await
257 .map(|()| ExitCode::SUCCESS)
258 }
259 };
260 match result {
261 Ok(code) => code,
262 Err(e) => fail(&e, output_format),
263 }
264 }
265 }
266}
267
268/// Maps a completed `tune`/`simulate` invocation's [`commands::tune::TuneOutcome`] to a
269/// process exit code -- the one place [`EXIT_ABORTED`]/[`EXIT_WRITE_BACK_FAILED`] are chosen.
270fn tune_outcome_exit_code(outcome: commands::tune::TuneOutcome) -> ExitCode {
271 match outcome {
272 commands::tune::TuneOutcome::Completed => ExitCode::SUCCESS,
273 commands::tune::TuneOutcome::Aborted => ExitCode::from(EXIT_ABORTED),
274 commands::tune::TuneOutcome::TimedOut => ExitCode::from(EXIT_TIMED_OUT),
275 commands::tune::TuneOutcome::WriteBackFailed => ExitCode::from(EXIT_WRITE_BACK_FAILED),
276 commands::tune::TuneOutcome::PoorQuality => ExitCode::from(EXIT_POOR_QUALITY),
277 commands::tune::TuneOutcome::ActuationFailed => ExitCode::from(EXIT_ACTUATION_FAILED),
278 commands::tune::TuneOutcome::RestoreIncomplete => ExitCode::from(EXIT_RESTORE_INCOMPLETE),
279 }
280}
281
282fn fail(err: &anyhow::Error, format: OutputFormat) -> ExitCode {
283 eprintln!("{}", output::format_error(err, format));
284 ExitCode::from(EXIT_FAILURE)
285}
286
287#[cfg(test)]
288mod tests {
289 use super::*;
290 use crate::args::{Command, ControllerTypeArg, DriverKindArg, ProcessTypeArg, TuneArgs};
291 use std::path::PathBuf;
292
293 fn temp_db_path() -> (tempfile::TempDir, PathBuf) {
294 let dir = tempfile::tempdir().unwrap();
295 let path = dir.path().join("bhtune.db");
296 (dir, path)
297 }
298
299 #[tokio::test]
300 async fn run_with_cli_config_load_failure_is_exit_failure() {
301 // An existing *file* occupying where a parent directory needs to go is a hard,
302 // portable error: `db::ensure_parent_dir`'s `create_dir_all` auto-creates a merely
303 // *missing* directory tree (deliberate first-run UX, see that function's doc
304 // comment) on every platform, so a bare nonexistent path is not reliably a failure
305 // trigger at all -- confirmed by hand, it does not fail on Linux either. What
306 // `create_dir_all` can never do on any OS is turn an existing regular file into a
307 // directory, so nesting the DB path under a plain file forces a portable, guaranteed
308 // failure. (An earlier version of this test used a hardcoded Unix-style absolute
309 // path like `/nonexistent-dir/bhtune.db`, relying on root-owned `/` rejecting
310 // directory creation for an unprivileged user -- that's a Linux permissions quirk,
311 // not a portable one: a leading `/`/`\` with no drive letter resolves relative to
312 // the current drive on Windows, and the `windows` CI job caught it landing somewhere
313 // writable there instead of failing.)
314 let dir = tempfile::tempdir().unwrap();
315 let blocker = dir.path().join("blocker");
316 std::fs::write(&blocker, b"not a directory").unwrap();
317 let cli = Cli {
318 db: Some(blocker.join("bhtune.db")),
319 config: None,
320 templates: None,
321 retention_days: None,
322 log_level: None,
323 log_dir: None,
324 log_format: None,
325 log_rotation: None,
326 command: Command::Template {
327 command: crate::args::TemplateCommand::List,
328 },
329 };
330 assert_eq!(run_with_cli(cli).await, ExitCode::FAILURE);
331 }
332
333 #[tokio::test]
334 async fn run_with_cli_explicit_config_path_failure_is_exit_failure() {
335 // An explicit `--config` path that doesn't exist is a hard error (unlike
336 // auto-discovery, which silently falls back to defaults) -- confirms `run_with_cli`
337 // surfaces `config::load_config`'s error before ever touching the database.
338 let cli = Cli {
339 db: None,
340 config: Some(PathBuf::from("/nonexistent/bhtune.toml")),
341 templates: None,
342 retention_days: None,
343 log_level: None,
344 log_dir: None,
345 log_format: None,
346 log_rotation: None,
347 command: Command::Template {
348 command: crate::args::TemplateCommand::List,
349 },
350 };
351 assert_eq!(run_with_cli(cli).await, ExitCode::FAILURE);
352 }
353
354 #[tokio::test]
355 async fn run_with_cli_templates_load_failure_is_exit_failure() {
356 // An explicit `--templates` path that doesn't exist is a hard error (unlike
357 // auto-discovery, which is not an error) -- confirms `run_with_cli` surfaces
358 // `config::load_user_templates`'s error before ever calling `db::open`.
359 let (_dir, db) = temp_db_path();
360 let cli = Cli {
361 db: Some(db),
362 config: None,
363 templates: Some(PathBuf::from("/nonexistent/templates.toml")),
364 retention_days: None,
365 log_level: None,
366 log_dir: None,
367 log_format: None,
368 log_rotation: None,
369 command: Command::Template {
370 command: crate::args::TemplateCommand::List,
371 },
372 };
373 assert_eq!(run_with_cli(cli).await, ExitCode::FAILURE);
374 }
375
376 #[tokio::test]
377 async fn run_with_cli_success_is_exit_success() {
378 let (_dir, db) = temp_db_path();
379 let cli = Cli {
380 db: Some(db),
381 config: None,
382 templates: None,
383 retention_days: None,
384 log_level: None,
385 log_dir: None,
386 log_format: None,
387 log_rotation: None,
388 command: Command::Template {
389 command: crate::args::TemplateCommand::List,
390 },
391 };
392 assert_eq!(run_with_cli(cli).await, ExitCode::SUCCESS);
393 }
394
395 #[tokio::test]
396 async fn run_with_cli_command_error_is_exit_failure() {
397 let (_dir, db) = temp_db_path();
398 let cli = Cli {
399 db: Some(db),
400 config: None,
401 templates: None,
402 retention_days: None,
403 log_level: None,
404 log_dir: None,
405 log_format: None,
406 log_rotation: None,
407 command: Command::Tune(TuneArgs {
408 tagname: "Unit1.LIC101.PV".to_string(),
409 template: "Nonexistent Template".to_string(),
410 process_type: ProcessTypeArg::Flow,
411 controller_type: ControllerTypeArg::Pi,
412 relay_amp: 10.0,
413 cycles_skip: None,
414 cycles_count: None,
415 noise_protection_secs: None,
416 mrft_delay: 0,
417 driver: DriverKindArg::Simulator,
418 bridge_host: None,
419 server: None,
420 sim_gain: 1.0,
421 sim_tau: 2.0,
422 sim_dead_time: 5.0,
423 sim_noise: 0.0,
424 sim_seed: 0,
425 sim_initial_pv: 50.0,
426 sim_initial_mv: 50.0,
427 pv_range_high: Some(100.0),
428 pv_range_low: Some(0.0),
429 mv_range_high: Some(100.0),
430 mv_range_low: Some(0.0),
431 direction: Some(crate::args::DirectionArg::Reverse),
432 tag_overrides: None,
433 poll_interval_ms: 800,
434 // Keep this dispatch test bounded even if a mutation prevents the
435 // simulator from completing.
436 timeout_secs: 30,
437 notes: None,
438 yes: false,
439 write_pid: None,
440 op_timeout_secs: 30,
441 restore_timeout_secs: 30,
442 output: OutputFormat::Table,
443 }),
444 };
445 assert_eq!(run_with_cli(cli).await, ExitCode::FAILURE);
446 }
447
448 #[test]
449 fn fail_prints_and_returns_exit_failure_in_table_format() {
450 let err = anyhow::anyhow!("boom");
451 assert_eq!(fail(&err, OutputFormat::Table), ExitCode::FAILURE);
452 }
453
454 #[test]
455 fn fail_returns_exit_failure_in_json_format_too() {
456 // `fail`'s exit code is always `EXIT_FAILURE` regardless of the requested output
457 // format -- only the printed message shape changes (see `output::format_error`).
458 let err = anyhow::anyhow!("boom");
459 assert_eq!(fail(&err, OutputFormat::Json), ExitCode::FAILURE);
460 }
461
462 #[test]
463 fn tune_outcome_exit_code_maps_every_variant() {
464 assert_eq!(
465 tune_outcome_exit_code(commands::tune::TuneOutcome::Completed),
466 ExitCode::SUCCESS
467 );
468 assert_eq!(
469 tune_outcome_exit_code(commands::tune::TuneOutcome::Aborted),
470 ExitCode::from(EXIT_ABORTED)
471 );
472 assert_eq!(
473 tune_outcome_exit_code(commands::tune::TuneOutcome::TimedOut),
474 ExitCode::from(EXIT_TIMED_OUT)
475 );
476 assert_eq!(
477 tune_outcome_exit_code(commands::tune::TuneOutcome::WriteBackFailed),
478 ExitCode::from(EXIT_WRITE_BACK_FAILED)
479 );
480 assert_eq!(
481 tune_outcome_exit_code(commands::tune::TuneOutcome::PoorQuality),
482 ExitCode::from(EXIT_POOR_QUALITY)
483 );
484 assert_eq!(
485 tune_outcome_exit_code(commands::tune::TuneOutcome::ActuationFailed),
486 ExitCode::from(EXIT_ACTUATION_FAILED)
487 );
488 assert_eq!(
489 tune_outcome_exit_code(commands::tune::TuneOutcome::RestoreIncomplete),
490 ExitCode::from(EXIT_RESTORE_INCOMPLETE)
491 );
492 }
493
494 /// Every `SimulateArgs` field explicitly set to a fast-converging demo run (mirroring
495 /// `commands::tune::tests::fast_simulator_args`), so `Command::Simulate`'s dispatch test
496 /// below finishes in well under a second rather than using the real 800 ms default.
497 fn fast_simulate_args() -> crate::args::SimulateArgs {
498 crate::args::SimulateArgs {
499 tagname: "Sim.Loop1.PV".to_string(),
500 template: "Yokogawa CentumVP".to_string(),
501 process_type: ProcessTypeArg::Flow,
502 controller_type: ControllerTypeArg::Pi,
503 relay_amp: 10.0,
504 cycles_skip: Some(1),
505 cycles_count: Some(2),
506 noise_protection_secs: Some(0),
507 mrft_delay: 0,
508 sim_gain: 1.0,
509 sim_tau: 0.01,
510 sim_dead_time: 0.025,
511 sim_noise: 0.0,
512 sim_seed: 0,
513 sim_initial_pv: 50.0,
514 sim_initial_mv: 50.0,
515 poll_interval_ms: 5,
516 // Keep this dispatch test bounded even if a mutation prevents the
517 // simulator from completing.
518 timeout_secs: 5,
519 notes: Some("dispatch test".to_string()),
520 yes: false,
521 write_pid: None,
522 op_timeout_secs: 30,
523 restore_timeout_secs: 30,
524 output: OutputFormat::Table,
525 }
526 }
527
528 #[tokio::test]
529 async fn run_with_cli_dispatches_simulate_history_export_and_opc() {
530 let (_dir, db) = temp_db_path();
531
532 assert_eq!(
533 run_with_cli(Cli {
534 db: Some(db.clone()),
535 config: None,
536 templates: None,
537 retention_days: None,
538 log_level: None,
539 log_dir: None,
540 log_format: None,
541 log_rotation: None,
542 command: Command::Simulate(fast_simulate_args()),
543 })
544 .await,
545 ExitCode::SUCCESS
546 );
547
548 // Look the run up directly so `History`/`Export` dispatch against a real run id
549 // rather than a placeholder that would only exercise their own error paths.
550 let pool = bhtune_db::connect(&db).await.unwrap();
551 let runs = bhtune_db::models::TuneRunRow::list(
552 &pool,
553 &bhtune_db::models::TuneRunFilter::default(),
554 bhtune_db::models::Pagination::first(1),
555 )
556 .await
557 .unwrap();
558 let run_id = runs[0].id;
559 pool.close().await;
560
561 assert_eq!(
562 run_with_cli(Cli {
563 db: Some(db.clone()),
564 config: None,
565 templates: None,
566 retention_days: None,
567 log_level: None,
568 log_dir: None,
569 log_format: None,
570 log_rotation: None,
571 command: Command::History {
572 command: crate::args::HistoryCommand::Show {
573 run_id,
574 output: OutputFormat::Table,
575 },
576 },
577 })
578 .await,
579 ExitCode::SUCCESS
580 );
581
582 assert_eq!(
583 run_with_cli(Cli {
584 db: Some(db.clone()),
585 config: None,
586 templates: None,
587 retention_days: None,
588 log_level: None,
589 log_dir: None,
590 log_format: None,
591 log_rotation: None,
592 command: Command::Export(crate::args::ExportArgs {
593 run_id,
594 format: crate::args::ExportFormat::Json,
595 output: None,
596 }),
597 })
598 .await,
599 ExitCode::SUCCESS
600 );
601
602 // `Command::Opc`'s own subcommand behavior is already covered directly in
603 // `commands::opc`'s tests; here we only need to prove the dispatch arm is reached —
604 // an unreachable gateway host fails promptly and still counts as "dispatched".
605 assert_eq!(
606 run_with_cli(Cli {
607 db: Some(db),
608 config: None,
609 templates: None,
610 retention_days: None,
611 log_level: None,
612 log_dir: None,
613 log_format: None,
614 log_rotation: None,
615 command: Command::Opc {
616 output: OutputFormat::Table,
617 command: crate::args::OpcCommand::Read {
618 bridge_host: Some("127.0.0.1:1".to_string()),
619 server: Some("Sim.Server".to_string()),
620 tags: vec!["Unit1.LIC101.PV".to_string()],
621 },
622 },
623 })
624 .await,
625 ExitCode::FAILURE
626 );
627 }
628
629 #[tokio::test]
630 async fn run_with_cli_resolves_db_path_from_config_file_when_cli_flag_is_unset() {
631 let (_dir, db) = temp_db_path();
632 let mut config_file = tempfile::NamedTempFile::new().unwrap();
633 use std::io::Write;
634 writeln!(config_file, "db = {:?}", db.to_str().unwrap()).unwrap();
635
636 let cli = Cli {
637 db: None,
638 config: Some(config_file.path().to_path_buf()),
639 templates: None,
640 retention_days: None,
641 log_level: None,
642 log_dir: None,
643 log_format: None,
644 log_rotation: None,
645 command: Command::Template {
646 command: crate::args::TemplateCommand::List,
647 },
648 };
649 assert_eq!(run_with_cli(cli).await, ExitCode::SUCCESS);
650 assert!(db.exists());
651 }
652
653 #[test]
654 fn startup_config_errors_use_the_requested_failure_format() {
655 let missing = std::path::Path::new("config-that-does-not-exist.toml");
656 assert_eq!(
657 load_startup_config(Some(missing), OutputFormat::Table),
658 Err(ExitCode::FAILURE)
659 );
660 }
661}