Skip to main content

bhtune_cli/commands/
check.rs

1//! Read-only tune preflight command.
2
3use std::process::ExitCode;
4
5use bhtune_core::DcsTemplate;
6use bhtune_db::SqlitePool;
7use bhtune_runtime::{
8    config::BhtuneConfig,
9    tune::{
10        PreflightCheck, PreflightCheckStatus, PreflightReport, PreflightTagRead,
11        ValidatedTuneRequest, preflight,
12    },
13};
14use serde::Serialize;
15
16use crate::{EXIT_CHECK_FAILED, args::CheckArgs, output::OutputFormat};
17
18#[derive(Serialize)]
19struct CheckJson<'a> {
20    status: &'static str,
21    strict: bool,
22    checks: &'a [PreflightCheck],
23    tag_reads: &'a [PreflightTagRead],
24}
25
26pub(crate) async fn run(
27    args: CheckArgs,
28    config: &BhtuneConfig,
29    database: Option<&SqlitePool>,
30    user_templates: Option<&[DcsTemplate]>,
31) -> anyhow::Result<ExitCode> {
32    let output = args.tune.output;
33    let strict = args.strict;
34    let report = preflight(
35        ValidatedTuneRequest::try_from(args.tune)?,
36        config,
37        database,
38        user_templates,
39    )
40    .await?;
41    print_report(&report, output, strict)?;
42
43    Ok(exit_code(&report, strict))
44}
45
46fn exit_code(report: &PreflightReport, strict: bool) -> ExitCode {
47    if report.passes(strict) {
48        ExitCode::SUCCESS
49    } else {
50        ExitCode::from(EXIT_CHECK_FAILED)
51    }
52}
53
54fn print_report(
55    report: &PreflightReport,
56    output: OutputFormat,
57    strict: bool,
58) -> anyhow::Result<()> {
59    let status = if report.passes(strict) {
60        report.status()
61    } else {
62        PreflightCheckStatus::Fail
63    };
64    match output {
65        OutputFormat::Json => {
66            let json = CheckJson {
67                status: status.as_str(),
68                strict,
69                checks: &report.checks,
70                tag_reads: &report.tag_reads,
71            };
72            println!("{}", serde_json::to_string_pretty(&json)?);
73        }
74        OutputFormat::Table => print_table(report, status, strict),
75    }
76    Ok(())
77}
78
79fn print_table(report: &PreflightReport, status: PreflightCheckStatus, strict: bool) {
80    println!("Preflight: {}", status.as_str());
81    if strict && report.has_warnings() && !report.has_failures() {
82        println!("  --strict treats warnings as failures");
83    }
84
85    println!();
86    println!("{:<6} {:<30} DETAIL", "STATUS", "CHECK");
87    for check in &report.checks {
88        println!(
89            "{:<6} {:<30} {}",
90            check.status.as_str(),
91            check.name,
92            check.detail
93        );
94    }
95
96    if !report.tag_reads.is_empty() {
97        println!();
98        println!(
99            "{:<6} {:<36} {:<28} {:<10} {:<11} DETAIL",
100            "STATUS", "TAG", "ROLES", "VALUE", "QUALITY"
101        );
102        for read in &report.tag_reads {
103            println!(
104                "{:<6} {:<36} {:<28} {:<10} {:<11} {}",
105                read.status.as_str(),
106                read.tag,
107                read.roles.join(","),
108                read.value.as_deref().unwrap_or("-"),
109                read.quality.as_deref().unwrap_or("-"),
110                read.detail
111            );
112        }
113    }
114}
115
116#[cfg(test)]
117mod tests {
118    use std::process::ExitCode;
119
120    use bhtune_runtime::tune::{
121        PreflightCheck, PreflightCheckStatus, PreflightReport, PreflightTagRead,
122    };
123
124    use clap::Parser;
125
126    use super::{CheckJson, exit_code, print_report};
127    use crate::EXIT_CHECK_FAILED;
128    use crate::output::OutputFormat;
129
130    #[test]
131    fn strict_mode_changes_warning_summary_to_failure() {
132        let report = PreflightReport {
133            checks: vec![PreflightCheck {
134                name: "compatibility".to_string(),
135                status: PreflightCheckStatus::Warn,
136                detail: "unknown".to_string(),
137            }],
138            tag_reads: Vec::new(),
139        };
140        assert_eq!(exit_code(&report, false), ExitCode::SUCCESS);
141        assert_eq!(exit_code(&report, true), ExitCode::from(EXIT_CHECK_FAILED));
142    }
143
144    #[tokio::test]
145    async fn check_returns_exit_eight_when_a_check_fails() {
146        let dir = tempfile::tempdir().unwrap();
147        let db = dir.path().join("missing.db");
148        let config = dir.path().join("bhtune.toml");
149        std::fs::write(&config, "").unwrap();
150        let cli = crate::args::Cli::try_parse_from([
151            "bhtune".to_string(),
152            "--db".to_string(),
153            db.display().to_string(),
154            "--config".to_string(),
155            config.display().to_string(),
156            "check".to_string(),
157            "--driver".to_string(),
158            "simulator".to_string(),
159            "--tagname".to_string(),
160            "Sim.Loop1.PV".to_string(),
161            "--template".to_string(),
162            "Yokogawa CentumVP".to_string(),
163            "--process-type".to_string(),
164            "flow".to_string(),
165            "--controller-type".to_string(),
166            "pi".to_string(),
167            "--relay-amp".to_string(),
168            "0".to_string(),
169            "--pv-range-high".to_string(),
170            "100".to_string(),
171            "--pv-range-low".to_string(),
172            "0".to_string(),
173            "--mv-range-high".to_string(),
174            "100".to_string(),
175            "--mv-range-low".to_string(),
176            "0".to_string(),
177            "--direction".to_string(),
178            "reverse".to_string(),
179            "--output".to_string(),
180            "json".to_string(),
181        ])
182        .unwrap();
183
184        assert_eq!(
185            crate::run_with_cli(cli).await,
186            ExitCode::from(EXIT_CHECK_FAILED)
187        );
188        assert!(!db.exists());
189    }
190
191    #[test]
192    fn json_report_includes_overall_status_strict_setting_and_check_details() {
193        let report = bhtune_runtime::tune::PreflightReport {
194            checks: vec![PreflightCheck {
195                name: "configuration".to_string(),
196                status: PreflightCheckStatus::Pass,
197                detail: "valid".to_string(),
198            }],
199            tag_reads: Vec::new(),
200        };
201        let output = serde_json::to_string(&CheckJson {
202            status: report.status().as_str(),
203            strict: false,
204            checks: &report.checks,
205            tag_reads: &report.tag_reads,
206        })
207        .unwrap();
208        let value: serde_json::Value = serde_json::from_str(&output).unwrap();
209        assert_eq!(value["status"], "pass");
210        assert_eq!(value["strict"], false);
211        assert_eq!(value["checks"][0]["name"], "configuration");
212    }
213
214    #[test]
215    fn report_printing_handles_both_formats() {
216        let report = PreflightReport {
217            checks: vec![PreflightCheck {
218                name: "configuration".to_string(),
219                status: PreflightCheckStatus::Pass,
220                detail: "valid".to_string(),
221            }],
222            tag_reads: vec![PreflightTagRead {
223                tag: "Unit1.LIC101.PV".to_string(),
224                roles: vec!["process_variable".to_string()],
225                value: None,
226                quality: None,
227                status: PreflightCheckStatus::Fail,
228                detail: "driver returned no value".to_string(),
229            }],
230        };
231        print_report(&report, OutputFormat::Json, false).unwrap();
232        print_report(&report, OutputFormat::Table, false).unwrap();
233
234        let warning_report = PreflightReport {
235            checks: vec![PreflightCheck {
236                name: "compatibility".to_string(),
237                status: PreflightCheckStatus::Warn,
238                detail: "not verified".to_string(),
239            }],
240            tag_reads: Vec::new(),
241        };
242        print_report(&warning_report, OutputFormat::Table, true).unwrap();
243    }
244}