1#![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
72pub const EXIT_SUCCESS: u8 = 0;
75pub const EXIT_FAILURE: u8 = 1;
79pub const EXIT_ABORTED: u8 = 2;
83pub const EXIT_WRITE_BACK_FAILED: u8 = 3;
91pub const EXIT_TIMED_OUT: u8 = 4;
99pub const EXIT_POOR_QUALITY: u8 = 5;
109pub const EXIT_RESTORE_INCOMPLETE: u8 = 6;
119pub const EXIT_ACTUATION_FAILED: u8 = 7;
124pub const EXIT_CHECK_FAILED: u8 = 8;
127
128pub 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 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#[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
200async fn run_with_cli_and_ctrl_c(cli: Cli, mut ctrl_c: cancel::CtrlC) -> ExitCode {
209 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
306fn 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 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 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 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 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 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 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 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 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 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}