Skip to main content

bhtune_cli/
lib.rs

1//! `bhtune` — the headless adapter (Rust library target `bhtune_cli`).
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`], [`db`], [`driver`], [`logging`], and [`retention`] — re-exports of
10//!   `bhtune-runtime` services used by CLI command handlers. Configuration precedence,
11//!   database bootstrap/seeding, driver construction, logging, and retention behavior are
12//!   shared with the HTTP adapter rather than implemented here.
13//! - [`commands`] — one module per subcommand family: `check`, `tune`/`simulate`,
14//!   `template`, `history`, `export`, `opc`.
15//! - [`output`] — CLI-specific `--output table|json` formatting and error presentation;
16//!   shared sample export serialization is provided by `bhtune-runtime`.
17//!
18//! `main.rs` stays a one-line delegator to [`run`]; `run_with_cli` is the internal entry
19//! point, kept separate so tests can exercise it against an already-parsed [`args::Cli`]
20//! without needing to control `std::env::args()` — mirroring `opcda-bridge-client`'s
21//! `run`/`run_with_cli` split. Logging is initialized in [`run`], not `run_with_cli`, for
22//! the same reason: it keeps tracing setup (and its process-global, only-succeeds-once
23//! subscriber installation) entirely out of `run_with_cli`'s own large, injection-based test
24//! suite -- see `logging`'s test module doc comment.
25//!
26//! Non-interactive/scheduled use (cron, CI, batch campaigns) is `tune`/`simulate`'s
27//! `--yes`/`--write-pid <level>` flags (bypassing the interactive write-back prompt) plus
28//! the global `[tuning]` timing configuration, which mandatorily bounds unattended runs and
29//! caps individual driver operations, and this module's distinguished exit codes
30//! ([`EXIT_ABORTED`], [`EXIT_TIMED_OUT`],
31//! [`EXIT_POOR_QUALITY`], [`EXIT_ACTUATION_FAILED`], [`EXIT_WRITE_BACK_FAILED`],
32//! [`EXIT_RESTORE_INCOMPLETE`]), so a
33//! scheduler can tell "aborted", "timed out", "the plant data couldn't be trusted", "test ran
34//! but the write-back failed", "the loop may not have been fully restored", and "never ran at
35//! all" apart without parsing stdout. See AGENTS.md's `cli-automation`/`cli-safety` sections.
36
37#![cfg_attr(
38    test,
39    allow(
40        clippy::unwrap_used,
41        clippy::expect_used,
42        clippy::panic,
43        reason = "unit tests may use unwrap, expect, and panic; production code must return typed errors"
44    )
45)]
46
47pub mod args;
48pub mod commands;
49pub mod output;
50pub use bhtune_runtime::{cancel, config, db, driver, gateway, logging, retention};
51
52use std::process::ExitCode;
53
54use clap::Parser;
55
56use args::{Cli, Command};
57use output::OutputFormat;
58
59enum DatabaseCommand {
60    Tune(Box<args::TuneArgs>),
61    Simulate(args::SimulateArgs),
62    Template(args::TemplateCommand),
63    History(args::HistoryCommand),
64    RestoreLoop(args::RestoreLoopArgs),
65    Export(args::ExportArgs),
66    Opc {
67        output: OutputFormat,
68        command: args::OpcCommand,
69    },
70}
71
72/// Process exited normally, and if this was `tune`/`simulate`, any PID write-back either
73/// succeeded or was cleanly skipped. Equal to [`ExitCode::SUCCESS`].
74pub const EXIT_SUCCESS: u8 = 0;
75/// A setup problem (bad flags, an unreadable config file, a database error, an unexpected
76/// driver error) prevented the command from running to completion at all. Equal to
77/// [`ExitCode::FAILURE`].
78pub const EXIT_FAILURE: u8 = 1;
79/// A `tune`/`simulate` run was aborted (Ctrl+C) before it finished; the loop was restored to
80/// its pre-test mode. Distinct from [`EXIT_FAILURE`] so a scheduler can tell "someone
81/// intentionally stopped this" apart from "this broke".
82pub const EXIT_ABORTED: u8 = 2;
83/// A `tune`/`simulate` run completed the MRFT test itself, but writing the selected PID
84/// constants back to the DCS failed (the write was rejected, errored, or its confirmation
85/// readback didn't match). Distinct from both [`EXIT_SUCCESS`] (nothing to report) and
86/// [`EXIT_FAILURE`] (the test itself never produced a result) so an unattended
87/// `--write-pid`/`--yes` run can tell "the test ran fine but the loop was NOT updated" apart
88/// from either of those. See `commands::tune::TuneOutcome` and AGENTS.md's `cli-automation`
89/// section.
90pub const EXIT_WRITE_BACK_FAILED: u8 = 3;
91/// A `tune`/`simulate` run was aborted because `[tuning].timeout_secs` elapsed before the
92/// engine reported completion; the loop was restored to its pre-test mode, exactly like
93/// [`EXIT_ABORTED`]. Distinct from it so a scheduler's alerting can tell "this run had to be
94/// killed for running too long" (possibly a stuck relay, a misconfigured tag mapping, or a
95/// stalled driver read -- worth investigating) apart from "an operator stopped it on
96/// purpose" (routine). See `commands::tune::TuneOutcome::TimedOut` and AGENTS.md's
97/// `cli-safety` section.
98pub const EXIT_TIMED_OUT: u8 = 4;
99/// A `tune`/`simulate` run was aborted because a driver reported a non-`Good` OPC quality
100/// for a tuning-critical reading (finding 5 of the live-plant safety review): an initial
101/// reading, the transition-to-manual setpoint capture, or an in-flight PV poll sample, and
102/// (for the in-flight case) the global Config > OPC quality policy rejected `Uncertain`, or
103/// the quality was `Bad` rather than merely `Uncertain`. The loop was restored to its pre-test
104/// mode, exactly like [`EXIT_ABORTED`]/[`EXIT_TIMED_OUT`]. Distinct from both so a scheduler's alerting can
105/// tell "the plant data itself couldn't be trusted" apart from a user-initiated stop or a
106/// run that simply took too long. See `commands::tune::TuneOutcome::PoorQuality` and
107/// AGENTS.md's `safety-quality` section.
108pub const EXIT_POOR_QUALITY: u8 = 5;
109/// A `tune`/`simulate` run ended (via normal completion, Ctrl+C, or a timeout) without being
110/// able to confirm the loop was fully restored to its pre-test mode/MV/setpoint -- either a
111/// second Ctrl+C was received while the restore was in flight, or
112/// `[tuning].restore_timeout_secs` elapsed first. Distinct from every other exit code because
113/// it means the loop may have
114/// been left mutated with no further attempt made to fix it: an operator must check it by
115/// hand, using the tag/value named in the warning printed to stderr. See
116/// `commands::tune::TuneOutcome::RestoreIncomplete` and AGENTS.md's `safety-cancellation`
117/// section.
118pub const EXIT_RESTORE_INCOMPLETE: u8 = 6;
119/// A live OPC DA tune was aborted because an accepted MV command could not be confirmed at
120/// the controller before its deadline or before a replacement relay command was required.
121/// The ordinary restore path still ran; [`EXIT_RESTORE_INCOMPLETE`] takes precedence if that
122/// restore could not itself be confirmed.
123pub const EXIT_ACTUATION_FAILED: u8 = 7;
124/// A `check` preflight completed, but one or more checks failed or `--strict` rejected a
125/// warning. The command never starts a tune.
126pub const EXIT_CHECK_FAILED: u8 = 8;
127
128/// Parses real CLI arguments, initializes structured logging, and runs, returning a process
129/// exit code.
130///
131/// Logging is resolved and initialized here rather than in `run_with_cli` -- see the crate
132/// doc comment. It reads the config file once, purely for `[log]` settings; `run_with_cli`
133/// reads it again moments later for the database path and other settings. That small,
134/// one-time duplication keeps `run_with_cli`'s own extensively unit-tested call path (which
135/// never touches a real log directory) fully decoupled from tracing setup, which can only
136/// ever be installed once per process. A logging setup failure (e.g. an unwritable log
137/// directory) is deliberately non-fatal -- see [`logging::init_tracing`] -- so it never
138/// prevents the actual command (and its `println!`-based result) from running.
139///
140/// [`cancel::CtrlC::install`] is called here, as the very first line, rather than inside
141/// `run_with_cli` or anywhere later -- deliberately earlier than the Ctrl+C listener's
142/// strict minimum requirement (registered once before the polling loop starts), so that a
143/// Ctrl+C pressed during config loading, logging setup, database open/migrate/seed, or the
144/// initial-readings/mode-transition sequence is also captured rather than lost or hitting
145/// the OS default (process kill, skipping the loop restore entirely). See
146/// `safety-cancellation` in AGENTS.md.
147pub async fn run() -> ExitCode {
148    let ctrl_c = cancel::CtrlC::install();
149    let cli = Cli::parse();
150
151    let output_format = cli.command.output_format();
152    let config = match load_startup_config(cli.config.as_deref(), output_format) {
153        Ok(config) => config,
154        Err(code) => return code,
155    };
156    let default_log_dir = config::default_log_dir_from(
157        std::env::var("XDG_DATA_HOME").ok().as_deref(),
158        std::env::var("HOME").ok().as_deref(),
159        std::env::var("APPDATA").ok().as_deref(),
160        cfg!(target_os = "windows"),
161    );
162    let log_settings = logging::resolve_log_settings(
163        cli.log_level.clone(),
164        cli.log_dir.clone(),
165        cli.log_format.clone(),
166        cli.log_rotation.clone(),
167        &config.log,
168        &default_log_dir,
169    );
170    // Held for the rest of this function's scope, which is the whole remaining lifetime of
171    // the process (`main.rs` immediately returns whatever `ExitCode` this call resolves to)
172    // -- dropping it any earlier would risk silently truncating buffered log lines.
173    let _log_guard = logging::init_tracing(&log_settings);
174
175    run_with_cli_and_ctrl_c(cli, ctrl_c).await
176}
177
178fn load_startup_config(
179    path: Option<&std::path::Path>,
180    output: OutputFormat,
181) -> Result<config::BhtuneConfig, ExitCode> {
182    config::load_config(path).map_err(|error| fail(&error, output))
183}
184
185/// Test-facing entry point: exercises [`run_with_cli_and_ctrl_c`] against an already-parsed
186/// [`args::Cli`] with a manually-created, untriggered cancellation handle, so the large
187/// existing test suite built around this function never installs a real process-wide signal
188/// handler -- see `cancel`'s module doc comment for why that matters beyond just this crate's
189/// own tests.
190/// Real process startup ([`run`]) calls [`run_with_cli_and_ctrl_c`] directly with a real,
191/// installed [`cancel::CtrlC`] instead of going through this wrapper. `#[cfg(test)]`-gated
192/// (rather than merely unused outside tests) because it is an injection seam for the crate's
193/// tests and callers should use [`run`] for process startup.
194#[cfg(test)]
195pub(crate) async fn run_with_cli(cli: Cli) -> ExitCode {
196    let (ctrl_c, _handle) = cancel::CtrlC::manual();
197    run_with_cli_and_ctrl_c(cli, ctrl_c).await
198}
199
200/// Loads the config file, resolves the database path, dispatches to the requested
201/// subcommand, and reports any error.
202///
203/// Config loading and DB-path resolution happen here (not inside `db::open` or each
204/// `commands::*::run`) because this is the one call site that has access to real process
205/// environment variables (`XDG_DATA_HOME`/`HOME`/`APPDATA`) -- everything downstream of this
206/// function takes already-resolved values or the loaded [`config::BhtuneConfig`] itself,
207/// keeping the config-precedence logic in `config.rs` fully unit-testable by injection.
208async fn run_with_cli_and_ctrl_c(cli: Cli, mut ctrl_c: cancel::CtrlC) -> ExitCode {
209    // Captured before `cli.command` is moved into the dispatch match below, so a config/db
210    // error can still be reported in the format the command actually asked for.
211    let output_format = cli.command.output_format();
212
213    let config = match config::load_config(cli.config.as_deref()) {
214        Ok(config) => config,
215        Err(e) => return fail(&e, output_format),
216    };
217    let db_path = config::resolve_db_path(
218        cli.db,
219        &config,
220        std::env::var("XDG_DATA_HOME").ok().as_deref(),
221        std::env::var("HOME").ok().as_deref(),
222        std::env::var("APPDATA").ok().as_deref(),
223        cfg!(target_os = "windows"),
224    );
225    let user_templates = match config::load_user_templates(
226        cli.templates,
227        &config,
228        std::env::var("XDG_CONFIG_HOME").ok().as_deref(),
229        std::env::var("HOME").ok().as_deref(),
230        std::env::var("APPDATA").ok().as_deref(),
231        cfg!(target_os = "windows"),
232    ) {
233        Ok(templates) => templates,
234        Err(e) => return fail(&e, output_format),
235    };
236    let retention_days = config::resolve_retention_days(cli.retention_days, &config);
237
238    let command = match cli.command {
239        Command::Check(args) => {
240            let database = match db::open_read_only(&db_path).await {
241                Ok(database) => database,
242                Err(error) => return fail(&error, output_format),
243            };
244            let result =
245                commands::check::run(args, &config, database.as_ref(), user_templates.as_deref())
246                    .await;
247            return match result {
248                Ok(code) => code,
249                Err(error) => fail(&error, output_format),
250            };
251        }
252        Command::Tune(args) => DatabaseCommand::Tune(Box::new(args)),
253        Command::Simulate(args) => DatabaseCommand::Simulate(args),
254        Command::Template { command } => DatabaseCommand::Template(command),
255        Command::History { command } => DatabaseCommand::History(command),
256        Command::RestoreLoop(args) => DatabaseCommand::RestoreLoop(args),
257        Command::Export(args) => DatabaseCommand::Export(args),
258        Command::Opc { output, command } => DatabaseCommand::Opc { output, command },
259    };
260
261    match db::open(&db_path, user_templates, retention_days).await {
262        Err(e) => fail(&e, output_format),
263        Ok(pool) => {
264            let result: anyhow::Result<ExitCode> = match command {
265                DatabaseCommand::Tune(args) => {
266                    commands::tune::run_with_ctrl_c(&pool, *args, &config, &mut ctrl_c)
267                        .await
268                        .map(tune_outcome_exit_code)
269                }
270                DatabaseCommand::Simulate(args) => commands::tune::run_with_ctrl_c(
271                    &pool,
272                    args.into_tune_args(),
273                    &config,
274                    &mut ctrl_c,
275                )
276                .await
277                .map(tune_outcome_exit_code),
278                DatabaseCommand::Template(command) => commands::template::run(&pool, command)
279                    .await
280                    .map(|()| ExitCode::SUCCESS),
281                DatabaseCommand::History(command) => {
282                    commands::history::run(&pool, command, &config)
283                        .await
284                        .map(|()| ExitCode::SUCCESS)
285                }
286                DatabaseCommand::RestoreLoop(args) => {
287                    commands::recovery::run(&pool, args, &mut ctrl_c).await
288                }
289                DatabaseCommand::Export(args) => commands::export::run(&pool, args)
290                    .await
291                    .map(|()| ExitCode::SUCCESS),
292                DatabaseCommand::Opc { output, command } => {
293                    commands::opc::run_with_output(&pool, command, &config, output)
294                        .await
295                        .map(|()| ExitCode::SUCCESS)
296                }
297            };
298            match result {
299                Ok(code) => code,
300                Err(e) => fail(&e, output_format),
301            }
302        }
303    }
304}
305
306/// Maps a completed `tune`/`simulate` invocation's [`commands::tune::TuneOutcome`] to a
307/// process exit code -- the one place [`EXIT_ABORTED`]/[`EXIT_WRITE_BACK_FAILED`] are chosen.
308fn tune_outcome_exit_code(outcome: commands::tune::TuneOutcome) -> ExitCode {
309    match outcome {
310        commands::tune::TuneOutcome::Completed => ExitCode::SUCCESS,
311        commands::tune::TuneOutcome::Aborted => ExitCode::from(EXIT_ABORTED),
312        commands::tune::TuneOutcome::TimedOut => ExitCode::from(EXIT_TIMED_OUT),
313        commands::tune::TuneOutcome::WriteBackFailed => ExitCode::from(EXIT_WRITE_BACK_FAILED),
314        commands::tune::TuneOutcome::PoorQuality => ExitCode::from(EXIT_POOR_QUALITY),
315        commands::tune::TuneOutcome::ActuationFailed => ExitCode::from(EXIT_ACTUATION_FAILED),
316        commands::tune::TuneOutcome::RestoreIncomplete => ExitCode::from(EXIT_RESTORE_INCOMPLETE),
317    }
318}
319
320fn fail(err: &anyhow::Error, format: OutputFormat) -> ExitCode {
321    eprintln!("{}", output::format_error(err, format));
322    ExitCode::from(EXIT_FAILURE)
323}
324
325#[cfg(test)]
326mod tests {
327    use super::*;
328    use crate::args::{Command, ControllerTypeArg, DriverKindArg, ProcessTypeArg, TuneArgs};
329    use clap::Parser;
330    use std::path::PathBuf;
331
332    fn temp_db_path() -> (tempfile::TempDir, PathBuf) {
333        let dir = tempfile::tempdir().unwrap();
334        let path = dir.path().join("bhtune.db");
335        (dir, path)
336    }
337
338    #[tokio::test]
339    async fn run_with_cli_returns_failure_for_pre_squash_migration_history() {
340        use sqlx::sqlite::{SqliteConnectOptions, SqlitePoolOptions};
341
342        let (dir, db) = temp_db_path();
343        let pool = SqlitePoolOptions::new()
344            .max_connections(1)
345            .connect_with(
346                SqliteConnectOptions::new()
347                    .filename(&db)
348                    .create_if_missing(true),
349            )
350            .await
351            .unwrap();
352        sqlx::query(
353            "CREATE TABLE _sqlx_migrations (\
354                version BIGINT PRIMARY KEY, description TEXT NOT NULL, \
355                installed_on TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, \
356                success BOOLEAN NOT NULL, checksum BLOB NOT NULL, execution_time BIGINT NOT NULL\
357            )",
358        )
359        .execute(&pool)
360        .await
361        .unwrap();
362        sqlx::query(
363            "INSERT INTO _sqlx_migrations (version, description, success, checksum, execution_time) \
364             VALUES (1, 'initial schema', TRUE, zeroblob(48), 0)",
365        )
366        .execute(&pool)
367        .await
368        .unwrap();
369        pool.close().await;
370        let config = dir.path().join("bhtune.toml");
371        std::fs::write(&config, "").unwrap();
372
373        let exit_code = run_with_cli(Cli {
374            db: Some(db.clone()),
375            config: Some(config),
376            templates: None,
377            retention_days: None,
378            log_level: None,
379            log_dir: Some(dir.path().join("logs")),
380            log_format: None,
381            log_rotation: None,
382            command: Command::Template {
383                command: crate::args::TemplateCommand::List,
384            },
385        })
386        .await;
387
388        assert_eq!(exit_code, ExitCode::FAILURE);
389        let pool = SqlitePoolOptions::new()
390            .max_connections(1)
391            .connect_with(SqliteConnectOptions::new().filename(&db).read_only(true))
392            .await
393            .unwrap();
394        let migrated_tables: i64 = sqlx::query_scalar(
395            "SELECT COUNT(*) FROM sqlite_schema WHERE type = 'table' AND name = 'tune_runs'",
396        )
397        .fetch_one(&pool)
398        .await
399        .unwrap();
400        assert_eq!(migrated_tables, 0);
401    }
402
403    #[tokio::test]
404    async fn run_with_cli_config_load_failure_is_exit_failure() {
405        // An existing *file* occupying where a parent directory needs to go is a hard,
406        // portable error: `db::ensure_parent_dir`'s `create_dir_all` auto-creates a merely
407        // *missing* directory tree (deliberate first-run UX, see that function's doc
408        // comment) on every platform, so a bare nonexistent path is not reliably a failure
409        // trigger at all -- confirmed by hand, it does not fail on Linux either. What
410        // `create_dir_all` can never do on any OS is turn an existing regular file into a
411        // directory, so nesting the DB path under a plain file forces a portable, guaranteed
412        // failure. (An earlier version of this test used a hardcoded Unix-style absolute
413        // path like `/nonexistent-dir/bhtune.db`, relying on root-owned `/` rejecting
414        // directory creation for an unprivileged user -- that's a Linux permissions quirk,
415        // not a portable one: a leading `/`/`\` with no drive letter resolves relative to
416        // the current drive on Windows, and the `windows` CI job caught it landing somewhere
417        // writable there instead of failing.)
418        let dir = tempfile::tempdir().unwrap();
419        let blocker = dir.path().join("blocker");
420        std::fs::write(&blocker, b"not a directory").unwrap();
421        let cli = Cli {
422            db: Some(blocker.join("bhtune.db")),
423            config: None,
424            templates: None,
425            retention_days: None,
426            log_level: None,
427            log_dir: None,
428            log_format: None,
429            log_rotation: None,
430            command: Command::Template {
431                command: crate::args::TemplateCommand::List,
432            },
433        };
434        assert_eq!(run_with_cli(cli).await, ExitCode::FAILURE);
435    }
436
437    #[tokio::test]
438    async fn run_with_cli_explicit_config_path_failure_is_exit_failure() {
439        // An explicit `--config` path that doesn't exist is a hard error (unlike
440        // auto-discovery, which silently falls back to defaults) -- confirms `run_with_cli`
441        // surfaces `config::load_config`'s error before ever touching the database.
442        let cli = Cli {
443            db: None,
444            config: Some(PathBuf::from("/nonexistent/bhtune.toml")),
445            templates: None,
446            retention_days: None,
447            log_level: None,
448            log_dir: None,
449            log_format: None,
450            log_rotation: None,
451            command: Command::Template {
452                command: crate::args::TemplateCommand::List,
453            },
454        };
455        assert_eq!(run_with_cli(cli).await, ExitCode::FAILURE);
456    }
457
458    #[tokio::test]
459    async fn run_with_cli_templates_load_failure_is_exit_failure() {
460        // An explicit `--templates` path that doesn't exist is a hard error (unlike
461        // auto-discovery, which is not an error) -- confirms `run_with_cli` surfaces
462        // `config::load_user_templates`'s error before ever calling `db::open`.
463        let (_dir, db) = temp_db_path();
464        let cli = Cli {
465            db: Some(db),
466            config: None,
467            templates: Some(PathBuf::from("/nonexistent/templates.toml")),
468            retention_days: None,
469            log_level: None,
470            log_dir: None,
471            log_format: None,
472            log_rotation: None,
473            command: Command::Template {
474                command: crate::args::TemplateCommand::List,
475            },
476        };
477        assert_eq!(run_with_cli(cli).await, ExitCode::FAILURE);
478    }
479
480    #[tokio::test]
481    async fn run_with_cli_success_is_exit_success() {
482        let (_dir, db) = temp_db_path();
483        let cli = Cli {
484            db: Some(db),
485            config: None,
486            templates: None,
487            retention_days: None,
488            log_level: None,
489            log_dir: None,
490            log_format: None,
491            log_rotation: None,
492            command: Command::Template {
493                command: crate::args::TemplateCommand::List,
494            },
495        };
496        assert_eq!(run_with_cli(cli).await, ExitCode::SUCCESS);
497    }
498
499    #[tokio::test]
500    async fn check_does_not_create_or_bootstrap_a_missing_database() {
501        let dir = tempfile::tempdir().unwrap();
502        let db = dir.path().join("missing.db");
503        let cli = Cli {
504            db: Some(db.clone()),
505            config: None,
506            templates: None,
507            retention_days: None,
508            log_level: None,
509            log_dir: None,
510            log_format: None,
511            log_rotation: None,
512            command: Command::Check(crate::args::CheckArgs {
513                tune: TuneArgs {
514                    tagname: "Sim.Loop1.PV".to_string(),
515                    template: "Yokogawa CentumVP".to_string(),
516                    process_type: ProcessTypeArg::Flow,
517                    controller_type: ControllerTypeArg::Pi,
518                    relay_amp: 10.0,
519                    cycles_skip: None,
520                    cycles_count: Some(2),
521                    noise_protection_secs: Some(0),
522                    mrft_delay: 0,
523                    driver: DriverKindArg::Simulator,
524                    bridge_host: None,
525                    server: None,
526                    sim_gain: 1.0,
527                    sim_tau: 0.01,
528                    sim_dead_time: 0.025,
529                    sim_noise: 0.0,
530                    sim_seed: 0,
531                    sim_initial_pv: 50.0,
532                    sim_initial_mv: 50.0,
533                    pv_range_high: Some(100.0),
534                    pv_range_low: Some(0.0),
535                    mv_range_high: Some(100.0),
536                    mv_range_low: Some(0.0),
537                    direction: Some(crate::args::DirectionArg::Reverse),
538                    tag_overrides: None,
539                    poll_interval_ms: 5,
540                    timeout_secs: 5,
541                    notes: None,
542                    yes: false,
543                    write_pid: None,
544                    op_timeout_secs: 30,
545                    restore_timeout_secs: 30,
546                    output: OutputFormat::Json,
547                },
548                strict: false,
549            }),
550        };
551
552        assert_eq!(run_with_cli(cli).await, ExitCode::SUCCESS);
553        assert!(!db.exists());
554    }
555
556    #[tokio::test]
557    async fn check_returns_exit_failure_when_opc_server_is_missing() {
558        let dir = tempfile::tempdir().unwrap();
559        let db = dir.path().join("missing.db");
560        let config = dir.path().join("bhtune.toml");
561        std::fs::write(&config, "").unwrap();
562        let cli = Cli::try_parse_from([
563            "bhtune".to_string(),
564            "--db".to_string(),
565            db.display().to_string(),
566            "--config".to_string(),
567            config.display().to_string(),
568            "check".to_string(),
569            "--driver".to_string(),
570            "opcda".to_string(),
571            "--tagname".to_string(),
572            "Area1.LIC101.PV".to_string(),
573            "--template".to_string(),
574            "Yokogawa CentumVP".to_string(),
575            "--process-type".to_string(),
576            "flow".to_string(),
577            "--controller-type".to_string(),
578            "pi".to_string(),
579            "--relay-amp".to_string(),
580            "5".to_string(),
581            "--output".to_string(),
582            "json".to_string(),
583        ])
584        .unwrap();
585
586        assert_eq!(run_with_cli(cli).await, ExitCode::from(EXIT_FAILURE));
587        assert!(!db.exists());
588    }
589
590    #[tokio::test]
591    async fn check_returns_exit_failure_when_read_only_database_open_fails() {
592        let dir = tempfile::tempdir().unwrap();
593        let db = dir.path();
594        let config = dir.path().join("bhtune.toml");
595        std::fs::write(&config, "").unwrap();
596        let cli = Cli::try_parse_from([
597            "bhtune".to_string(),
598            "--db".to_string(),
599            db.display().to_string(),
600            "--config".to_string(),
601            config.display().to_string(),
602            "check".to_string(),
603            "--driver".to_string(),
604            "simulator".to_string(),
605            "--tagname".to_string(),
606            "Sim.Loop1.PV".to_string(),
607            "--template".to_string(),
608            "Yokogawa CentumVP".to_string(),
609            "--process-type".to_string(),
610            "flow".to_string(),
611            "--controller-type".to_string(),
612            "pi".to_string(),
613            "--relay-amp".to_string(),
614            "5".to_string(),
615            "--output".to_string(),
616            "json".to_string(),
617        ])
618        .unwrap();
619
620        assert_eq!(run_with_cli(cli).await, ExitCode::from(EXIT_FAILURE));
621        assert!(db.is_dir());
622    }
623
624    #[tokio::test]
625    async fn run_with_cli_command_error_is_exit_failure() {
626        let (_dir, db) = temp_db_path();
627        let cli = Cli {
628            db: Some(db),
629            config: None,
630            templates: None,
631            retention_days: None,
632            log_level: None,
633            log_dir: None,
634            log_format: None,
635            log_rotation: None,
636            command: Command::Tune(TuneArgs {
637                tagname: "Unit1.LIC101.PV".to_string(),
638                template: "Nonexistent Template".to_string(),
639                process_type: ProcessTypeArg::Flow,
640                controller_type: ControllerTypeArg::Pi,
641                relay_amp: 10.0,
642                cycles_skip: None,
643                cycles_count: None,
644                noise_protection_secs: None,
645                mrft_delay: 0,
646                driver: DriverKindArg::Simulator,
647                bridge_host: None,
648                server: None,
649                sim_gain: 1.0,
650                sim_tau: 2.0,
651                sim_dead_time: 5.0,
652                sim_noise: 0.0,
653                sim_seed: 0,
654                sim_initial_pv: 50.0,
655                sim_initial_mv: 50.0,
656                pv_range_high: Some(100.0),
657                pv_range_low: Some(0.0),
658                mv_range_high: Some(100.0),
659                mv_range_low: Some(0.0),
660                direction: Some(crate::args::DirectionArg::Reverse),
661                tag_overrides: None,
662                poll_interval_ms: 800,
663                // Keep this dispatch test bounded even if a mutation prevents the
664                // simulator from completing.
665                timeout_secs: 30,
666                notes: None,
667                yes: false,
668                write_pid: None,
669                op_timeout_secs: 30,
670                restore_timeout_secs: 30,
671                output: OutputFormat::Table,
672            }),
673        };
674        assert_eq!(run_with_cli(cli).await, ExitCode::FAILURE);
675    }
676
677    #[test]
678    fn fail_prints_and_returns_exit_failure_in_table_format() {
679        let err = anyhow::anyhow!("boom");
680        assert_eq!(fail(&err, OutputFormat::Table), ExitCode::FAILURE);
681    }
682
683    #[test]
684    fn fail_returns_exit_failure_in_json_format_too() {
685        // `fail`'s exit code is always `EXIT_FAILURE` regardless of the requested output
686        // format -- only the printed message shape changes (see `output::format_error`).
687        let err = anyhow::anyhow!("boom");
688        assert_eq!(fail(&err, OutputFormat::Json), ExitCode::FAILURE);
689    }
690
691    #[test]
692    fn tune_outcome_exit_code_maps_every_variant() {
693        assert_eq!(
694            tune_outcome_exit_code(commands::tune::TuneOutcome::Completed),
695            ExitCode::SUCCESS
696        );
697        assert_eq!(
698            tune_outcome_exit_code(commands::tune::TuneOutcome::Aborted),
699            ExitCode::from(EXIT_ABORTED)
700        );
701        assert_eq!(
702            tune_outcome_exit_code(commands::tune::TuneOutcome::TimedOut),
703            ExitCode::from(EXIT_TIMED_OUT)
704        );
705        assert_eq!(
706            tune_outcome_exit_code(commands::tune::TuneOutcome::WriteBackFailed),
707            ExitCode::from(EXIT_WRITE_BACK_FAILED)
708        );
709        assert_eq!(
710            tune_outcome_exit_code(commands::tune::TuneOutcome::PoorQuality),
711            ExitCode::from(EXIT_POOR_QUALITY)
712        );
713        assert_eq!(
714            tune_outcome_exit_code(commands::tune::TuneOutcome::ActuationFailed),
715            ExitCode::from(EXIT_ACTUATION_FAILED)
716        );
717        assert_eq!(
718            tune_outcome_exit_code(commands::tune::TuneOutcome::RestoreIncomplete),
719            ExitCode::from(EXIT_RESTORE_INCOMPLETE)
720        );
721    }
722
723    #[test]
724    fn check_failure_uses_exit_code_eight() {
725        assert_eq!(EXIT_CHECK_FAILED, 8);
726        assert_eq!(ExitCode::from(EXIT_CHECK_FAILED), ExitCode::from(8));
727    }
728
729    /// Every `SimulateArgs` field explicitly set to a fast-converging demo run (mirroring
730    /// `commands::tune::tests::fast_simulator_args`), so `Command::Simulate`'s dispatch test
731    /// below finishes in well under a second rather than using the real 800 ms default.
732    fn fast_simulate_args() -> crate::args::SimulateArgs {
733        crate::args::SimulateArgs {
734            tagname: "Sim.Loop1.PV".to_string(),
735            template: "Yokogawa CentumVP".to_string(),
736            process_type: ProcessTypeArg::Flow,
737            controller_type: ControllerTypeArg::Pi,
738            relay_amp: 10.0,
739            cycles_skip: Some(1),
740            cycles_count: Some(2),
741            noise_protection_secs: Some(0),
742            mrft_delay: 0,
743            sim_gain: 1.0,
744            sim_tau: 0.01,
745            sim_dead_time: 0.025,
746            sim_noise: 0.0,
747            sim_seed: 0,
748            sim_initial_pv: 50.0,
749            sim_initial_mv: 50.0,
750            poll_interval_ms: 5,
751            // Keep this dispatch test bounded even if a mutation prevents the
752            // simulator from completing.
753            timeout_secs: 5,
754            notes: Some("dispatch test".to_string()),
755            yes: false,
756            write_pid: None,
757            op_timeout_secs: 30,
758            restore_timeout_secs: 30,
759            output: OutputFormat::Table,
760        }
761    }
762
763    #[tokio::test]
764    async fn run_with_cli_dispatches_simulate_history_export_and_opc() {
765        let (_dir, db) = temp_db_path();
766
767        assert_eq!(
768            run_with_cli(Cli {
769                db: Some(db.clone()),
770                config: None,
771                templates: None,
772                retention_days: None,
773                log_level: None,
774                log_dir: None,
775                log_format: None,
776                log_rotation: None,
777                command: Command::Simulate(fast_simulate_args()),
778            })
779            .await,
780            ExitCode::SUCCESS
781        );
782
783        // Look the run up directly so `History`/`Export` dispatch against a real run id
784        // rather than a placeholder that would only exercise their own error paths.
785        let pool = bhtune_db::connect(&db).await.unwrap();
786        let runs = bhtune_db::models::TuneRunRow::list(
787            &pool,
788            &bhtune_db::models::TuneRunFilter::default(),
789            bhtune_db::models::Pagination::first(1),
790        )
791        .await
792        .unwrap();
793        let run_id = runs[0].id;
794        pool.close().await;
795
796        assert_eq!(
797            run_with_cli(Cli {
798                db: Some(db.clone()),
799                config: None,
800                templates: None,
801                retention_days: None,
802                log_level: None,
803                log_dir: None,
804                log_format: None,
805                log_rotation: None,
806                command: Command::History {
807                    command: crate::args::HistoryCommand::Show {
808                        run_id,
809                        output: OutputFormat::Table,
810                    },
811                },
812            })
813            .await,
814            ExitCode::SUCCESS
815        );
816
817        assert_eq!(
818            run_with_cli(Cli {
819                db: Some(db.clone()),
820                config: None,
821                templates: None,
822                retention_days: None,
823                log_level: None,
824                log_dir: None,
825                log_format: None,
826                log_rotation: None,
827                command: Command::Export(crate::args::ExportArgs {
828                    run_id,
829                    format: crate::args::ExportFormat::Json,
830                    output: None,
831                }),
832            })
833            .await,
834            ExitCode::SUCCESS
835        );
836
837        // `Command::Opc`'s own subcommand behavior is already covered directly in
838        // `commands::opc`'s tests; here we only need to prove the dispatch arm is reached —
839        // an unreachable gateway host fails promptly and still counts as "dispatched".
840        assert_eq!(
841            run_with_cli(Cli {
842                db: Some(db.clone()),
843                config: None,
844                templates: None,
845                retention_days: None,
846                log_level: None,
847                log_dir: None,
848                log_format: None,
849                log_rotation: None,
850                command: Command::Opc {
851                    output: OutputFormat::Table,
852                    command: crate::args::OpcCommand::Read {
853                        bridge_host: Some("127.0.0.1:1".to_string()),
854                        server: Some("Sim.Server".to_string()),
855                        tags: vec!["Unit1.LIC101.PV".to_string()],
856                    },
857                },
858            })
859            .await,
860            ExitCode::FAILURE
861        );
862
863        assert_eq!(
864            run_with_cli(Cli {
865                db: Some(db),
866                config: None,
867                templates: None,
868                retention_days: None,
869                log_level: None,
870                log_dir: None,
871                log_format: None,
872                log_rotation: None,
873                command: Command::RestoreLoop(crate::args::RestoreLoopArgs {
874                    run_id: 999_999,
875                    bridge_host: None,
876                    server: None,
877                    yes: true,
878                    output: OutputFormat::Table,
879                }),
880            })
881            .await,
882            ExitCode::FAILURE
883        );
884    }
885
886    #[tokio::test]
887    async fn run_with_cli_resolves_db_path_from_config_file_when_cli_flag_is_unset() {
888        let (_dir, db) = temp_db_path();
889        let mut config_file = tempfile::NamedTempFile::new().unwrap();
890        use std::io::Write;
891        writeln!(config_file, "db = {:?}", db.to_str().unwrap()).unwrap();
892
893        let cli = Cli {
894            db: None,
895            config: Some(config_file.path().to_path_buf()),
896            templates: None,
897            retention_days: None,
898            log_level: None,
899            log_dir: None,
900            log_format: None,
901            log_rotation: None,
902            command: Command::Template {
903                command: crate::args::TemplateCommand::List,
904            },
905        };
906        assert_eq!(run_with_cli(cli).await, ExitCode::SUCCESS);
907        assert!(db.exists());
908    }
909
910    #[test]
911    fn startup_config_errors_use_the_requested_failure_format() {
912        let missing = std::path::Path::new("config-that-does-not-exist.toml");
913        assert_eq!(
914            load_startup_config(Some(missing), OutputFormat::Table),
915            Err(ExitCode::FAILURE)
916        );
917    }
918
919    #[cfg(test)]
920    mod config_example_tests {
921        use crate::config::{
922            BhtuneConfig, DemoPolicy, ServerMode, resolve_demo_policy_from_config,
923        };
924
925        #[test]
926        fn example_config_declares_the_approved_demo_contract() {
927            let config: BhtuneConfig =
928                toml::from_str(include_str!("../bhtune.example.toml")).unwrap();
929            assert_eq!(config.server_mode, Some(ServerMode::Full));
930            assert_eq!(
931                resolve_demo_policy_from_config(&config).unwrap(),
932                DemoPolicy::default()
933            );
934        }
935    }
936}