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