Skip to main content

bhtune_cli/commands/
export.rs

1//! `bhtune export <run_id>`: dumps a run's recorded samples as CSV or JSON.
2//!
3//! CSV rows are generated from the same `#[derive(Serialize)]` struct as the header, so the
4//! header and data columns cannot drift out of order.
5
6use std::io::Write;
7
8use bhtune_db::SqlitePool;
9use bhtune_db::models::TuneSampleRow;
10
11use crate::args::{ExportArgs, ExportFormat};
12
13/// Serializes a run's recorded samples to CSV or JSON bytes -- the one place this mapping is
14/// implemented, shared by this module's own `run()` (writing to a file or stdout) and
15/// `bhtune-server`'s `GET /api/runs/{id}/export` route, so the CLI
16/// and the web GUI can never disagree about what a run's export looks like.
17pub fn samples_to_bytes(
18    samples: &[TuneSampleRow],
19    format: ExportFormat,
20) -> anyhow::Result<Vec<u8>> {
21    let format = match format {
22        ExportFormat::Csv => bhtune_runtime::export::SampleExportFormat::Csv,
23        ExportFormat::Json => bhtune_runtime::export::SampleExportFormat::Json,
24    };
25    bhtune_runtime::export::samples_to_bytes(samples, format)
26}
27
28pub async fn run(pool: &SqlitePool, args: ExportArgs) -> anyhow::Result<()> {
29    let samples = TuneSampleRow::list_for_run(pool, args.run_id).await?;
30    if samples.is_empty() {
31        anyhow::bail!(
32            "run {} has no recorded samples (unknown run id, or it never started)",
33            args.run_id
34        );
35    }
36    let record_count = samples.len();
37    let bytes = samples_to_bytes(&samples, args.format)?;
38
39    match &args.output {
40        Some(path) => {
41            tokio::fs::write(path, &bytes)
42                .await
43                .map_err(|e| anyhow::anyhow!("failed to write '{}': {e}", path.display()))?;
44            println!(
45                "Exported {} sample(s) from run {} to '{}'.",
46                record_count,
47                args.run_id,
48                path.display()
49            );
50        }
51        None => {
52            std::io::stdout().write_all(&bytes)?;
53        }
54    }
55    Ok(())
56}
57
58#[cfg(test)]
59mod tests {
60    use super::*;
61    use bhtune_core::mrft::{MrftState, Tick};
62
63    async fn pool_with_one_sample() -> (SqlitePool, i64) {
64        let pool = bhtune_db::connect_in_memory().await.unwrap();
65        let now = chrono::Utc::now();
66        let template = bhtune_core::built_in_templates().remove(0);
67        let tags = bhtune_core::LoopTags::derive_from_pv_tag("Unit1.LIC101.PV", &template);
68        let run = bhtune_db::models::TuneRunRow::start(
69            &pool,
70            None,
71            "Unit1.LIC101.PV",
72            bhtune_db::models::TuneDriver::Simulator,
73            bhtune_core::LoopConfig {
74                process_type: bhtune_core::ProcessType::Flow,
75                controller_type: bhtune_core::ControllerType::Pi,
76                relay_amp_percent: 10.0,
77                num_cycles_skip: 1,
78                num_cycles_count: 2,
79                noise_protection_secs: 3,
80                mrft_delay_secs: 0,
81            },
82            bhtune_db::models::TemplateOrigin::Builtin,
83            &template,
84            &tags,
85            now,
86        )
87        .await
88        .unwrap();
89
90        TuneSampleRow::insert(
91            &pool,
92            run.id,
93            0,
94            Tick {
95                time: now,
96                pv: 50.0,
97            },
98            MrftState {
99                hysteresis: 0.0,
100                mv_value_current: 50.0,
101                mv_sign_next_step: 1,
102                counter_all_switches: 0,
103                cycles_completed: 0,
104                cycles_remaining: 2,
105            },
106            bhtune_db::models::SampleQuality::Good,
107        )
108        .await
109        .unwrap();
110
111        (pool, run.id)
112    }
113
114    /// Proves the shared [`samples_to_bytes`] function directly, decoupled from this
115    /// module's own file/stdout-writing `run()` -- this is the exact contract
116    /// `bhtune-server`'s `GET /api/runs/{id}/export` route also depends on.
117    #[tokio::test]
118    async fn samples_to_bytes_csv_matches_the_cli_export_shape() {
119        let (pool, run_id) = pool_with_one_sample().await;
120        let samples = TuneSampleRow::list_for_run(&pool, run_id).await.unwrap();
121        let bytes = samples_to_bytes(&samples, ExportFormat::Csv).unwrap();
122        let text = String::from_utf8(bytes).unwrap();
123        let mut lines = text.lines();
124        assert_eq!(
125            lines.next().unwrap(),
126            "tick,time,pv,pv_quality,hysteresis,mv_value_current,mv_sign_next_step,counter_all_switches,cycles_completed,cycles_remaining"
127        );
128        assert!(lines.next().unwrap().starts_with("0,"));
129    }
130
131    #[tokio::test]
132    async fn samples_to_bytes_json_matches_the_cli_export_shape() {
133        let (pool, run_id) = pool_with_one_sample().await;
134        let samples = TuneSampleRow::list_for_run(&pool, run_id).await.unwrap();
135        let bytes = samples_to_bytes(&samples, ExportFormat::Json).unwrap();
136        let parsed: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
137        assert_eq!(parsed[0]["pv"], 50.0);
138        assert_eq!(parsed[0]["tick"], 0);
139    }
140
141    #[tokio::test]
142    async fn exports_csv_to_stdout_when_no_output_path_given() {
143        let (pool, run_id) = pool_with_one_sample().await;
144        run(
145            &pool,
146            ExportArgs {
147                run_id,
148                format: ExportFormat::Csv,
149                output: None,
150            },
151        )
152        .await
153        .unwrap();
154    }
155
156    #[tokio::test]
157    async fn exports_csv_to_a_file_with_correct_columns() {
158        let (pool, run_id) = pool_with_one_sample().await;
159        let dir = tempfile::tempdir().unwrap();
160        let path = dir.path().join("run.csv");
161        run(
162            &pool,
163            ExportArgs {
164                run_id,
165                format: ExportFormat::Csv,
166                output: Some(path.clone()),
167            },
168        )
169        .await
170        .unwrap();
171
172        let contents = std::fs::read_to_string(&path).unwrap();
173        let mut lines = contents.lines();
174        let header = lines.next().unwrap();
175        assert_eq!(
176            header,
177            "tick,time,pv,pv_quality,hysteresis,mv_value_current,mv_sign_next_step,counter_all_switches,cycles_completed,cycles_remaining"
178        );
179        let data = lines.next().unwrap();
180        let fields: Vec<&str> = data.split(',').collect();
181        assert_eq!(fields[0], "0"); // tick
182        assert_eq!(fields[2], "50.0"); // pv, not swapped with mv
183        assert_eq!(fields[3], "good"); // pv_quality
184        assert_eq!(fields[5], "50.0"); // mv_value_current
185    }
186
187    #[tokio::test]
188    async fn exports_json_to_a_file() {
189        let (pool, run_id) = pool_with_one_sample().await;
190        let dir = tempfile::tempdir().unwrap();
191        let path = dir.path().join("run.json");
192        run(
193            &pool,
194            ExportArgs {
195                run_id,
196                format: ExportFormat::Json,
197                output: Some(path.clone()),
198            },
199        )
200        .await
201        .unwrap();
202
203        let contents = std::fs::read_to_string(&path).unwrap();
204        let parsed: serde_json::Value = serde_json::from_str(&contents).unwrap();
205        assert_eq!(parsed[0]["pv"], 50.0);
206    }
207
208    #[tokio::test]
209    async fn errors_for_a_run_with_no_samples() {
210        let pool = bhtune_db::connect_in_memory().await.unwrap();
211        let err = run(
212            &pool,
213            ExportArgs {
214                run_id: 999,
215                format: ExportFormat::Csv,
216                output: None,
217            },
218        )
219        .await
220        .unwrap_err();
221        assert!(err.to_string().contains("999"));
222    }
223
224    #[tokio::test]
225    async fn reports_the_destination_when_writing_an_export_fails() {
226        let (pool, run_id) = pool_with_one_sample().await;
227        let err = run(
228            &pool,
229            ExportArgs {
230                run_id,
231                format: ExportFormat::Csv,
232                output: Some(".".into()),
233            },
234        )
235        .await
236        .unwrap_err();
237
238        assert!(err.to_string().contains("failed to write '.'"));
239    }
240}