1use std::io::Write;
7
8use bhtune_db::SqlitePool;
9use bhtune_db::models::TuneSampleRow;
10
11use crate::args::{ExportArgs, ExportFormat};
12
13pub 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 #[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"); assert_eq!(fields[2], "50.0"); assert_eq!(fields[3], "good"); assert_eq!(fields[5], "50.0"); }
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}