Skip to main content

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}