Skip to main content

bhtune_cli/commands/
history.rs

1//! `bhtune history list/show/revert`.
2
3use bhtune_db::SqlitePool;
4use bhtune_db::models::{
5    MvActuationKind, MvActuationStatus, Pagination, SampleQuality, SamplingAdequacy, TimingSummary,
6    TuneDriver, TuneMvActuationRow, TuneResultRow, TuneRunFilter, TuneRunRow, TuneSampleRow,
7    TuneWriteRow, WriteKind,
8};
9use bhtune_driver::OpcDaDriver;
10
11use crate::args::HistoryCommand;
12use crate::output::OutputFormat;
13
14pub async fn run(
15    pool: &SqlitePool,
16    command: HistoryCommand,
17    config: &crate::config::BhtuneConfig,
18) -> anyhow::Result<()> {
19    match command {
20        HistoryCommand::List {
21            outcome,
22            limit,
23            offset,
24            output,
25        } => list(pool, outcome.map(Into::into), limit, offset, output).await,
26        HistoryCommand::Show { run_id, output } => show(pool, run_id, output).await,
27        HistoryCommand::Revert {
28            run_id,
29            bridge_host,
30            server,
31            yes,
32            output,
33        } => revert(pool, run_id, bridge_host, server, yes, output, config).await,
34        HistoryCommand::Prune {
35            older_than_days,
36            dry_run,
37            output,
38        } => prune(pool, config, older_than_days, dry_run, output).await,
39    }
40}
41
42/// The fields of one run shown in `history list`'s `--output json` array -- deliberately a
43/// subset matching the plain-text table's own columns exactly, not the full run detail
44/// (that's `RunDetailJson`, for `history show`).
45#[derive(serde::Serialize)]
46struct RunSummaryJson {
47    id: i64,
48    tag_name: String,
49    notes: Option<String>,
50    driver: bhtune_db::models::TuneDriver,
51    outcome: bhtune_db::models::TuneOutcome,
52    process_type: bhtune_core::ProcessType,
53    started_at: chrono::DateTime<chrono::Utc>,
54}
55
56#[derive(serde::Serialize)]
57struct RunListJson {
58    runs: Vec<RunSummaryJson>,
59    /// How many rows are in `runs` (i.e. this page) -- distinct from `total`, the count of
60    /// every run matching the filter across all pages.
61    returned: usize,
62    total: i64,
63}
64
65/// Local projection of [`bhtune_db::models::TuneRunInitialReadings`] for JSON output: that
66/// type deliberately doesn't derive `Serialize` itself (DB row shape stays decoupled from
67/// any API/CLI JSON shape), so `history show --output json` needs its own copy.
68#[derive(serde::Serialize)]
69struct InitialReadingsJson {
70    pv_ini: f32,
71    mv_ini: f32,
72    mv_range_low: f32,
73    mv_range_high: f32,
74    pv_range_high: f32,
75    pv_range_low: f32,
76    controller_direction: bhtune_core::ControllerDirection,
77    mode_raw: Option<String>,
78    mode_attribute_raw: Option<String>,
79    setpoint_ini: Option<f32>,
80}
81
82impl From<bhtune_db::models::TuneRunInitialReadings> for InitialReadingsJson {
83    fn from(r: bhtune_db::models::TuneRunInitialReadings) -> Self {
84        Self {
85            pv_ini: r.pv_ini,
86            mv_ini: r.mv_ini,
87            mv_range_low: r.mv_range_low,
88            mv_range_high: r.mv_range_high,
89            pv_range_high: r.pv_range_high,
90            pv_range_low: r.pv_range_low,
91            controller_direction: r.controller_direction,
92            mode_raw: r.mode_raw,
93            mode_attribute_raw: r.mode_attribute_raw,
94            setpoint_ini: r.setpoint_ini,
95        }
96    }
97}
98
99/// Local projection of [`bhtune_db::models::TuneResultRow`] for JSON output (see
100/// [`InitialReadingsJson`] for why this can't just derive `Serialize` on the DB type
101/// directly).
102#[derive(serde::Serialize)]
103struct ResultJson {
104    response_level: bhtune_core::ResponseLevel,
105    kp: Option<f32>,
106    ti_minutes: Option<f32>,
107    td_minutes: Option<f32>,
108    proportional: Option<f32>,
109    integral: Option<f32>,
110    derivative: Option<f32>,
111    status: bhtune_core::TuningResultStatus,
112    invalid_reason: Option<bhtune_core::TuningResultInvalidReason>,
113}
114
115impl From<&TuneResultRow> for ResultJson {
116    fn from(r: &TuneResultRow) -> Self {
117        Self {
118            response_level: r.response_level,
119            kp: r.kp,
120            ti_minutes: r.ti_minutes,
121            td_minutes: r.td_minutes,
122            proportional: r.proportional,
123            integral: r.integral,
124            derivative: r.derivative,
125            status: r.status,
126            invalid_reason: r.invalid_reason,
127        }
128    }
129}
130
131/// Local projection of [`bhtune_db::models::TuneWriteRow`] for JSON output (see
132/// [`InitialReadingsJson`] for why this can't just derive `Serialize` on the DB type
133/// directly).
134#[derive(serde::Serialize)]
135struct WriteJson {
136    /// Whether this is an original write-back of freshly calculated PID parameters, or a
137    /// `bhtune history revert` undoing one (see [`WriteKind`]).
138    kind: WriteKind,
139    response_level: bhtune_core::ResponseLevel,
140    written_at: chrono::DateTime<chrono::Utc>,
141    /// The P/I/D values read from the driver before any write was attempted. `None` only
142    /// when the pre-read itself failed, in which case every field below is also `None`.
143    proportional_previous: Option<f32>,
144    integral_previous: Option<f32>,
145    derivative_previous: Option<f32>,
146    /// `None` for a constant that was never attempted because an earlier one in the
147    /// P/I/D write-and-verify sequence had already failed.
148    proportional_written: Option<f32>,
149    integral_written: Option<f32>,
150    derivative_written: Option<f32>,
151    proportional_readback: Option<f32>,
152    integral_readback: Option<f32>,
153    derivative_readback: Option<f32>,
154    success: bool,
155    error_message: Option<String>,
156    /// `None` means no rollback was applicable -- either every constant wrote successfully
157    /// or the pre-read failed before any write was attempted; always `None` for a `Revert`
158    /// row. See `rollback_error` for what went wrong when this is
159    /// `Some(RollbackState::Failed)`.
160    rollback_state: Option<bhtune_db::models::RollbackState>,
161    rollback_error: Option<String>,
162}
163
164impl From<&TuneWriteRow> for WriteJson {
165    fn from(w: &TuneWriteRow) -> Self {
166        Self {
167            kind: w.kind,
168            response_level: w.response_level,
169            written_at: w.written_at,
170            proportional_previous: w.previous.map(|p| p.proportional),
171            integral_previous: w.previous.map(|p| p.integral),
172            derivative_previous: w.previous.map(|p| p.derivative),
173            proportional_written: w.proportional_written,
174            integral_written: w.integral_written,
175            derivative_written: w.derivative_written,
176            proportional_readback: w.proportional_readback,
177            integral_readback: w.integral_readback,
178            derivative_readback: w.derivative_readback,
179            success: w.success,
180            error_message: w.error_message.clone(),
181            rollback_state: w.rollback_state,
182            rollback_error: w.rollback_error.clone(),
183        }
184    }
185}
186
187#[derive(serde::Serialize)]
188struct MvActuationJson {
189    id: i64,
190    sequence: i64,
191    kind: MvActuationKind,
192    commanded_at: chrono::DateTime<chrono::Utc>,
193    target_mv: f32,
194    previous_commanded_mv: Option<f32>,
195    tolerance: f32,
196    confirmation_due_at: chrono::DateTime<chrono::Utc>,
197    last_checked_at: Option<chrono::DateTime<chrono::Utc>>,
198    readback_mv: Option<f32>,
199    readback_quality: Option<SampleQuality>,
200    attempt_count: i64,
201    status: MvActuationStatus,
202    detail: Option<String>,
203}
204
205impl From<&TuneMvActuationRow> for MvActuationJson {
206    fn from(row: &TuneMvActuationRow) -> Self {
207        Self {
208            id: row.id,
209            sequence: row.sequence,
210            kind: row.kind,
211            commanded_at: row.commanded_at,
212            target_mv: row.target_mv,
213            previous_commanded_mv: row.previous_commanded_mv,
214            tolerance: row.tolerance,
215            confirmation_due_at: row.confirmation_due_at,
216            last_checked_at: row.last_checked_at,
217            readback_mv: row.readback_mv,
218            readback_quality: row.readback_quality,
219            attempt_count: row.attempt_count,
220            status: row.status,
221            detail: row.detail.clone(),
222        }
223    }
224}
225
226#[derive(serde::Serialize)]
227struct RunDetailJson {
228    id: i64,
229    tag_name: String,
230    notes: Option<String>,
231    driver: bhtune_db::models::TuneDriver,
232    outcome: bhtune_db::models::TuneOutcome,
233    failure_reason: Option<String>,
234    started_at: chrono::DateTime<chrono::Utc>,
235    completed_at: Option<chrono::DateTime<chrono::Utc>>,
236    /// Name of the template snapshotted onto this run at start time -- not necessarily the
237    /// template `template_name` currently resolves to in the catalog, since templates can be
238    /// edited or re-versioned after a run is recorded (`safety-run-snapshot`).
239    template_name: String,
240    template_origin: bhtune_db::models::TemplateOrigin,
241    config: bhtune_core::LoopConfig,
242    /// The resolved OPC DA server ProgID this run actually used, or `None` for a
243    /// simulator/replay run (`db-run-request-snapshot`). This is what `history revert`
244    /// trusts over any `--server` flag, rather than re-resolving one.
245    opc_server: Option<String>,
246    /// The resolved bridge host this run actually used, matching `opc_server` above.
247    bridge_host: Option<String>,
248    initial_readings: Option<InitialReadingsJson>,
249    timing_metrics: Option<bhtune_db::models::TimingMetrics>,
250    samples_recorded: usize,
251    results: Vec<ResultJson>,
252    writes: Vec<WriteJson>,
253    mv_actuations: Vec<MvActuationJson>,
254    /// Outcome of the best-effort restore attempted after this run ended -- `None` if the
255    /// run never mutated the loop, or hasn't ended yet (`safety-restore-guard`).
256    restore_status: Option<bhtune_db::models::RestoreStatus>,
257    restore_detail: Option<String>,
258}
259
260async fn list(
261    pool: &SqlitePool,
262    outcome: Option<bhtune_db::models::TuneOutcome>,
263    limit: i64,
264    offset: i64,
265    output: OutputFormat,
266) -> anyhow::Result<()> {
267    let mut filter = TuneRunFilter::default();
268    if let Some(outcome) = outcome {
269        filter = filter.with_outcome(outcome);
270    }
271    let pagination = Pagination::new(limit, offset);
272    let runs = TuneRunRow::list(pool, &filter, pagination).await?;
273    let total = TuneRunRow::count(pool, &filter).await?;
274
275    match output {
276        OutputFormat::Table => {
277            if runs.is_empty() {
278                println!("No runs found.");
279                return Ok(());
280            }
281
282            println!(
283                "{:<5} {:<30} {:<10} {:<10} {:<10} {:<25}",
284                "ID", "TAG NAME", "DRIVER", "OUTCOME", "PROCESS", "STARTED"
285            );
286            for run in &runs {
287                println!(
288                    "{:<5} {:<30} {:<10} {:<10} {:<10} {:<25}",
289                    run.id,
290                    run.loop_name,
291                    format!("{:?}", run.driver),
292                    format!("{:?}", run.outcome),
293                    format!("{:?}", run.config.process_type),
294                    run.started_at.to_rfc3339(),
295                );
296            }
297            println!("Showing {} of {total} total run(s).", runs.len());
298        }
299        OutputFormat::Json => {
300            let json = RunListJson {
301                returned: runs.len(),
302                total,
303                runs: runs
304                    .iter()
305                    .map(|run| RunSummaryJson {
306                        id: run.id,
307                        tag_name: run.loop_name.clone(),
308                        notes: run.notes.clone(),
309                        driver: run.driver,
310                        outcome: run.outcome,
311                        process_type: run.config.process_type,
312                        started_at: run.started_at,
313                    })
314                    .collect(),
315            };
316            println!("{}", serde_json::to_string_pretty(&json)?);
317        }
318    }
319    Ok(())
320}
321
322fn has_rows<T>(rows: &[T]) -> bool {
323    !rows.is_empty()
324}
325
326fn has_recorded_connection(opc_server: Option<&str>, bridge_host: Option<&str>) -> bool {
327    opc_server.is_some() || bridge_host.is_some()
328}
329
330fn is_table_output(output: OutputFormat) -> bool {
331    output == OutputFormat::Table
332}
333
334/// `bhtune history prune` -- applies `history-retention`'s age-based policy on demand,
335/// instead of waiting for the next startup or (for `bhtune-server`) the next periodic sweep.
336///
337/// `older_than_days` overrides the configured `retention_days` policy for this invocation
338/// only, matching this project's usual per-invocation-flag-overrides-persistent-config
339/// pattern (mirroring `--db`/`--templates`); if neither is given there is nothing to prune
340/// against, and that's a plain error rather than silently doing nothing -- an operator who
341/// runs `bhtune history prune` clearly wants *something* deleted.
342///
343/// `--dry-run` reports a count and the exact cutoff timestamp that would be used, without
344/// deleting anything, via [`TuneRunRow::count`] against the same [`TuneRunFilter`] shape
345/// [`crate::retention::sweep_retention`] would delete against -- so a preview and the real
346/// run can never disagree about which runs match. It deliberately doesn't itemize every
347/// matching run (unlike `history list`, which paginates): the history table is allowed to be
348/// large, and a prune preview only needs to answer "how many, and as of when", matching the
349/// automatic sweep's own INFO log shape.
350async fn prune(
351    pool: &SqlitePool,
352    config: &crate::config::BhtuneConfig,
353    older_than_days: Option<u32>,
354    dry_run: bool,
355    output: OutputFormat,
356) -> anyhow::Result<()> {
357    let days = older_than_days.or(config.retention_days).ok_or_else(|| {
358        anyhow::anyhow!(
359            "no retention policy configured; pass --older-than-days, or set --retention-days \
360             / BHTUNE_RETENTION_DAYS / the config file's retention_days key"
361        )
362    })?;
363    let now = chrono::Utc::now();
364    let cutoff = crate::retention::cutoff_for(days, now);
365
366    if dry_run {
367        let count =
368            TuneRunRow::count(pool, &TuneRunFilter::default().with_started_before(cutoff)).await?;
369        match output {
370            OutputFormat::Table => {
371                println!(
372                    "Would delete {count} run(s) started at or before {} ({days} day(s)).",
373                    cutoff.to_rfc3339()
374                );
375            }
376            OutputFormat::Json => {
377                let json = PruneJson {
378                    retention_days: days,
379                    cutoff,
380                    dry_run: true,
381                    deleted: count as u64,
382                };
383                println!("{}", serde_json::to_string_pretty(&json)?);
384            }
385        }
386        return Ok(());
387    }
388
389    let deleted = crate::retention::sweep_retention(pool, days, now).await?;
390    match output {
391        OutputFormat::Table => {
392            println!(
393                "Deleted {deleted} run(s) started at or before {} ({days} day(s)).",
394                cutoff.to_rfc3339()
395            );
396        }
397        OutputFormat::Json => {
398            let json = PruneJson {
399                retention_days: days,
400                cutoff,
401                dry_run: false,
402                deleted,
403            };
404            println!("{}", serde_json::to_string_pretty(&json)?);
405        }
406    }
407    Ok(())
408}
409
410#[derive(serde::Serialize)]
411struct PruneJson {
412    retention_days: u32,
413    cutoff: chrono::DateTime<chrono::Utc>,
414    dry_run: bool,
415    deleted: u64,
416}
417
418/// Renders an optional PID constant reading/write for `history show`'s plain-text table --
419/// `"-"` for `None` (never attempted, or the pre-read failed), 4 decimal places otherwise.
420fn fmt_opt_f32(value: Option<f32>) -> String {
421    match value {
422        Some(v) => format!("{v:.4}"),
423        None => "-".to_string(),
424    }
425}
426
427fn fmt_opt_f64(value: Option<f64>) -> String {
428    match value {
429        Some(v) => format!("{v:.3}"),
430        None => "-".to_string(),
431    }
432}
433
434fn sampling_adequacy_label(adequacy: SamplingAdequacy) -> &'static str {
435    match adequacy {
436        SamplingAdequacy::Adequate => "adequate",
437        SamplingAdequacy::Marginal => "marginal",
438        SamplingAdequacy::NotAssessed => "not assessed",
439    }
440}
441
442fn print_latency_summary(label: &str, summary: TimingSummary) {
443    if summary.count == 0 {
444        println!("    {label:<26} none");
445        return;
446    }
447
448    println!(
449        "    {label:<26} count={} mean={} ms max={} ms",
450        summary.count,
451        fmt_opt_f64(summary.mean_ms),
452        fmt_opt_f64(summary.max_ms),
453    );
454}
455
456fn print_show_table(
457    run: &TuneRunRow,
458    samples: &[TuneSampleRow],
459    results: &[TuneResultRow],
460    writes: &[TuneWriteRow],
461    mv_actuations: &[TuneMvActuationRow],
462) {
463    println!("Run #{} — Tag name: {}", run.id, run.loop_name);
464    println!("  Notes:           {}", run.notes.as_deref().unwrap_or("—"));
465    println!("  Driver:          {:?}", run.driver);
466    println!("  Outcome:          {:?}", run.outcome);
467    if let Some(reason) = &run.failure_reason {
468        println!("  Failure reason:   {reason}");
469    }
470    println!("  Started at:       {}", run.started_at.to_rfc3339());
471    if let Some(completed_at) = run.completed_at {
472        println!("  Completed at:     {}", completed_at.to_rfc3339());
473    }
474    println!(
475        "  Template:         {} ({:?})",
476        run.template.name, run.template_origin
477    );
478    if has_recorded_connection(run.opc_server.as_deref(), run.bridge_host.as_deref()) {
479        println!(
480            "  Connection:       server={} bridge_host={}",
481            run.opc_server.as_deref().unwrap_or("-"),
482            run.bridge_host.as_deref().unwrap_or("-"),
483        );
484    }
485    println!(
486        "  Process/controller: {:?} / {:?}",
487        run.config.process_type, run.config.controller_type
488    );
489    println!("  Relay amplitude:  {}%", run.config.relay_amp_percent);
490    println!(
491        "  Cycles skip/count: {} / {}",
492        run.config.num_cycles_skip, run.config.num_cycles_count
493    );
494
495    if let Some(readings) = &run.initial_readings {
496        println!(
497            "  Initial PV / MV:  {} / {}",
498            readings.pv_ini, readings.mv_ini
499        );
500        println!(
501            "  MV range:         {} - {}",
502            readings.mv_range_low, readings.mv_range_high
503        );
504        println!(
505            "  PV range:         {} - {}",
506            readings.pv_range_low, readings.pv_range_high
507        );
508        println!("  Direction:        {:?}", readings.controller_direction);
509    }
510
511    println!("  Samples recorded: {}", samples.len());
512
513    if let Some(timing) = run.timing_metrics {
514        println!("  Timing:");
515        println!("    Basis:                    {:?}", timing.basis);
516        println!(
517            "    Requested interval:       {} ms",
518            timing.requested_interval_ms
519        );
520        println!("    Observed sample gaps:     {}", timing.sample_gap_count);
521        println!(
522            "    Mean / max sample gap:    {} / {} ms",
523            fmt_opt_f64(timing.mean_sample_gap_ms),
524            fmt_opt_f64(timing.max_sample_gap_ms)
525        );
526        println!(
527            "    Missed poll opportunities: {}",
528            timing.missed_poll_opportunity_count
529        );
530        println!(
531            "    Oscillation period:       {} ms",
532            fmt_opt_f64(timing.measured_oscillation_period_ms)
533        );
534        println!(
535            "    Approx. samples / period: {}",
536            fmt_opt_f64(timing.approximate_samples_per_period)
537        );
538        println!(
539            "    Sampling adequacy:        {}",
540            sampling_adequacy_label(timing.sampling_adequacy)
541        );
542        if let Some(poll_latency) = timing.poll_latency {
543            println!("    Poll latency:");
544            print_latency_summary("PV reads:", poll_latency.pv_read);
545            print_latency_summary("MV writes:", poll_latency.mv_write);
546            print_latency_summary("MV verification:", poll_latency.mv_verification);
547            print_latency_summary("Sample persistence:", poll_latency.sample_persist);
548            print_latency_summary("Total tick work:", poll_latency.tick_work);
549        }
550    }
551
552    match (run.restore_status, &run.restore_detail) {
553        (Some(bhtune_db::models::RestoreStatus::Confirmed), _) => {
554            println!("  Restore:          confirmed");
555        }
556        (Some(bhtune_db::models::RestoreStatus::Incomplete), detail) => {
557            println!(
558                "  Restore:          INCOMPLETE -- {}",
559                detail.as_deref().unwrap_or("no detail recorded")
560            );
561        }
562        (None, _) => {}
563    }
564
565    print_show_results(results);
566    print_show_writes(writes);
567    print_show_mv_actuations(mv_actuations);
568}
569
570fn print_show_results(results: &[TuneResultRow]) {
571    if !has_rows(results) {
572        return;
573    }
574    println!("  Calculated results:");
575    println!(
576        "    {:<12} {:<10} {:<10} {:<10} {:<10} {:<12} {:<10} {:<10}  REASON",
577        "LEVEL", "STATUS", "KP", "TI(min)", "TD(min)", "PROP", "INTEGRAL", "DERIV"
578    );
579    for result in results {
580        println!(
581            "    {:<12} {:<10} {:<10} {:<10} {:<10} {:<12} {:<10} {:<10}  {}",
582            format!("{:?}", result.response_level),
583            format!("{:?}", result.status),
584            fmt_opt_f32(result.kp),
585            fmt_opt_f32(result.ti_minutes),
586            fmt_opt_f32(result.td_minutes),
587            fmt_opt_f32(result.proportional),
588            fmt_opt_f32(result.integral),
589            fmt_opt_f32(result.derivative),
590            result
591                .invalid_reason
592                .map(|reason| reason.to_string())
593                .unwrap_or_else(|| "-".to_string())
594        );
595    }
596}
597
598fn print_show_writes(writes: &[TuneWriteRow]) {
599    if !has_rows(writes) {
600        return;
601    }
602    println!("  PID write-back audit:");
603    for write in writes {
604        println!(
605            "    [{}] {:?} ({:?} level): success={} previous(P={} I={} D={}) \
606             written(P={} I={} D={}) readback(P={} I={} D={}){}",
607            write.written_at.to_rfc3339(),
608            write.kind,
609            write.response_level,
610            write.success,
611            fmt_opt_f32(write.previous.map(|p| p.proportional)),
612            fmt_opt_f32(write.previous.map(|p| p.integral)),
613            fmt_opt_f32(write.previous.map(|p| p.derivative)),
614            fmt_opt_f32(write.proportional_written),
615            fmt_opt_f32(write.integral_written),
616            fmt_opt_f32(write.derivative_written),
617            fmt_opt_f32(write.proportional_readback),
618            fmt_opt_f32(write.integral_readback),
619            fmt_opt_f32(write.derivative_readback),
620            write
621                .error_message
622                .as_ref()
623                .map(|message| format!(" error={message}"))
624                .unwrap_or_default(),
625        );
626        print_show_rollback(write);
627    }
628}
629
630fn print_show_rollback(write: &TuneWriteRow) {
631    let Some(rollback_state) = write.rollback_state else {
632        return;
633    };
634    println!(
635        "        rollback: {rollback_state:?}{}",
636        write
637            .rollback_error
638            .as_ref()
639            .map(|message| format!(" error={message}"))
640            .unwrap_or_default(),
641    );
642}
643
644fn print_show_mv_actuations(actuations: &[TuneMvActuationRow]) {
645    if !has_rows(actuations) {
646        return;
647    }
648    println!("  MV actuation verification:");
649    println!(
650        "    {:<5} {:<8} {:<25} {:<10} {:<10} {:<10} {:<10} {:<12} DETAIL",
651        "SEQ", "KIND", "COMMANDED", "TARGET", "READBACK", "TOLERANCE", "ATTEMPTS", "STATUS"
652    );
653    for actuation in actuations {
654        println!(
655            "    {:<5} {:<8} {:<25} {:<10} {:<10} {:<10} {:<10} {:<12} {}",
656            actuation.sequence,
657            format!("{:?}", actuation.kind),
658            actuation.commanded_at.to_rfc3339(),
659            fmt_opt_f32(Some(actuation.target_mv)),
660            fmt_opt_f32(actuation.readback_mv),
661            fmt_opt_f32(Some(actuation.tolerance)),
662            actuation.attempt_count,
663            format!("{:?}", actuation.status),
664            actuation.detail.as_deref().unwrap_or("-"),
665        );
666        println!(
667            "        confirmation due: {}  last checked: {}  quality: {}",
668            actuation.confirmation_due_at.to_rfc3339(),
669            actuation
670                .last_checked_at
671                .map(|value| value.to_rfc3339())
672                .unwrap_or_else(|| "-".to_string()),
673            actuation
674                .readback_quality
675                .map(|quality| format!("{quality:?}"))
676                .unwrap_or_else(|| "-".to_string()),
677        );
678    }
679}
680
681fn print_show_json(
682    run: &TuneRunRow,
683    samples: &[TuneSampleRow],
684    results: &[TuneResultRow],
685    writes: &[TuneWriteRow],
686    mv_actuations: &[TuneMvActuationRow],
687) -> anyhow::Result<()> {
688    let json = RunDetailJson {
689        id: run.id,
690        tag_name: run.loop_name.clone(),
691        notes: run.notes.clone(),
692        driver: run.driver,
693        outcome: run.outcome,
694        failure_reason: run.failure_reason.clone(),
695        started_at: run.started_at,
696        completed_at: run.completed_at,
697        template_name: run.template.name.clone(),
698        template_origin: run.template_origin,
699        config: run.config,
700        opc_server: run.opc_server.clone(),
701        bridge_host: run.bridge_host.clone(),
702        initial_readings: run
703            .initial_readings
704            .as_ref()
705            .map(|readings| InitialReadingsJson::from(readings.clone())),
706        timing_metrics: run.timing_metrics,
707        samples_recorded: samples.len(),
708        results: results.iter().map(ResultJson::from).collect(),
709        writes: writes.iter().map(WriteJson::from).collect(),
710        mv_actuations: mv_actuations.iter().map(MvActuationJson::from).collect(),
711        restore_status: run.restore_status,
712        restore_detail: run.restore_detail.clone(),
713    };
714    println!("{}", serde_json::to_string_pretty(&json)?);
715    Ok(())
716}
717
718async fn show(pool: &SqlitePool, run_id: i64, output: OutputFormat) -> anyhow::Result<()> {
719    let run = TuneRunRow::get(pool, run_id)
720        .await?
721        .ok_or_else(|| anyhow::anyhow!("no run with id {run_id}"))?;
722    let samples = TuneSampleRow::list_for_run(pool, run_id).await?;
723    let results = TuneResultRow::list_for_run(pool, run_id).await?;
724    let writes = TuneWriteRow::list_for_run(pool, run_id).await?;
725    let mv_actuations = TuneMvActuationRow::list_for_run(pool, run_id).await?;
726
727    match output {
728        OutputFormat::Table => print_show_table(&run, &samples, &results, &writes, &mv_actuations),
729        OutputFormat::Json => print_show_json(&run, &samples, &results, &writes, &mv_actuations)?,
730    }
731
732    Ok(())
733}
734
735/// The result of an attempted revert, in `--output json` mode -- `history revert`'s
736/// equivalent of `tune`'s [`WriteBackOutcome`], but never printed as prose ahead of it (see
737/// this function's own doc comment for why).
738#[derive(serde::Serialize)]
739struct RevertJson {
740    run_id: i64,
741    /// The response level of the write-back being undone (recorded on the original `Write`
742    /// row; the revert's own audit row is written under the same response level).
743    response_level: bhtune_core::ResponseLevel,
744    reverted_to: RevertedTargetJson,
745    success: bool,
746    error_message: Option<String>,
747}
748
749#[derive(serde::Serialize)]
750struct RevertedTargetJson {
751    proportional: f32,
752    integral: f32,
753    derivative: f32,
754}
755
756/// Resolves the OPC DA connection a revert should use for `run` -- always its own recorded
757/// `opc_server`/`bridge_host` (`db-run-request-snapshot`), never a value re-resolved from
758/// `--server`/`--bridge-host`/config at revert time. Re-resolving at revert time is exactly
759/// the bug this closes: running `history revert` from a shell whose flags/config point at a
760/// *different* gateway would otherwise confidently write the wrong plant's old PID constants
761/// under tag names that may happen to exist on both. An explicit `--server`/`--bridge-host`
762/// flag is still accepted, but purely as a cross-check against the recorded value -- a
763/// contradicting flag is a hard error, never a silent override, and there is no fallback to
764/// config when a flag is omitted (unlike every other command's connection resolution).
765fn resolve_revert_connection(
766    run: &TuneRunRow,
767    bridge_host_flag: Option<&str>,
768    server_flag: Option<&str>,
769) -> anyhow::Result<(String, String)> {
770    let stored_server = run.opc_server.as_deref().ok_or_else(|| {
771        anyhow::anyhow!(
772            "run {}'s recorded OPC server is missing; refusing to guess which server to \
773             revert against",
774            run.id
775        )
776    })?;
777    let stored_bridge_host = run.bridge_host.as_deref().ok_or_else(|| {
778        anyhow::anyhow!(
779            "run {}'s recorded bridge host is missing; refusing to guess which gateway to \
780             revert against",
781            run.id
782        )
783    })?;
784
785    if let Some(server_flag) = server_flag
786        && server_flag != stored_server
787    {
788        anyhow::bail!(
789            "--server {server_flag:?} contradicts run {}'s recorded OPC server \
790             {stored_server:?}; refusing to revert against a different server than the run \
791             actually used -- omit --server to use the recorded value",
792            run.id
793        );
794    }
795    if let Some(bridge_host_flag) = bridge_host_flag
796        && bridge_host_flag != stored_bridge_host
797    {
798        anyhow::bail!(
799            "--bridge-host {bridge_host_flag:?} contradicts run {}'s recorded bridge host \
800             {stored_bridge_host:?}; refusing to revert through a different gateway than the \
801             run actually used -- omit --bridge-host to use the recorded value",
802            run.id
803        );
804    }
805
806    Ok((stored_bridge_host.to_string(), stored_server.to_string()))
807}
808
809/// `bhtune history revert <run-id>`: writes a run's recorded pre-write-back PID values back
810/// to the live loop, undoing whichever [`WriteKind::Write`] write-back that run last
811/// recorded (`safety-writeback-rollback`, finding 6's revert companion command). Reuses
812/// `commands::tune`'s own pre-read/write-and-verify machinery
813/// ([`crate::commands::tune::read_previous_pid_values`]/
814/// [`crate::commands::tune::write_and_verify_pid_value`]), so a revert is audited exactly
815/// like an original write -- a new [`TuneWriteRow`] with `kind = WriteKind::Revert` -- the
816/// one difference being that a revert never attempts a nested rollback of itself if it
817/// fails partway through (see [`WriteKind`]'s doc comment for why).
818///
819/// The OPC DA connection is never re-resolved from `--server`/`--bridge-host`/config the way
820/// every other command resolves it -- see [`resolve_revert_connection`] for why that would
821/// be a live-plant safety bug, not just an inconsistency.
822///
823/// Every rejection that happens *before* anything is attempted (no such run, wrong driver,
824/// no write-back recorded, its pre-read failed so there is nothing to revert to, missing
825/// `--yes`, no PID constant tags, a contradicting/missing recorded connection, or a failed
826/// connection) is a plain `Err`, which `lib.rs`'s existing `fail()` reports through
827/// `--output json`'s own error contract -- exactly the same path `history show`'s "no such
828/// run" error already takes. Only the outcome of an *attempted* revert is reported here, and
829/// only ever as prose gated on `output == OutputFormat::Table` (never unconditionally,
830/// unlike `tune`'s own write-back step -- see finding 8) or as the one `RevertJson` object
831/// printed on success.
832#[allow(clippy::too_many_arguments)]
833async fn revert(
834    pool: &SqlitePool,
835    run_id: i64,
836    bridge_host: Option<String>,
837    server: Option<String>,
838    yes: bool,
839    output: OutputFormat,
840    config: &crate::config::BhtuneConfig,
841) -> anyhow::Result<()> {
842    let allow_uncertain_quality = config.allow_uncertain_quality;
843    let run = TuneRunRow::get(pool, run_id)
844        .await?
845        .ok_or_else(|| anyhow::anyhow!("no run with id {run_id}"))?;
846
847    if run.driver != TuneDriver::Opcda {
848        anyhow::bail!(
849            "run {run_id} used the {:?} driver, which has no live loop to revert a write \
850             against",
851            run.driver
852        );
853    }
854
855    let writes = TuneWriteRow::list_for_run(pool, run_id).await?;
856    let last_write = writes
857        .iter()
858        .rev()
859        .find(|w| w.kind == WriteKind::Write)
860        .ok_or_else(|| anyhow::anyhow!("run {run_id} has no recorded PID write-back to revert"))?;
861    let response_level = last_write.response_level;
862    let target = last_write.previous.ok_or_else(|| {
863        anyhow::anyhow!(
864            "run {run_id}'s {response_level:?} PID write-back never recorded pre-write \
865             values (its pre-read failed at the time); nothing to revert to"
866        )
867    })?;
868
869    if !yes {
870        anyhow::bail!("reverting writes PID constants back to a live loop; pass --yes to confirm");
871    }
872
873    let (Some(p_tag), Some(i_tag), Some(d_tag)) = (
874        &run.tags.proportional_constant,
875        &run.tags.integral_constant,
876        &run.tags.derivative_constant,
877    ) else {
878        anyhow::bail!("run {run_id}'s snapshotted tags have no PID constant tags configured");
879    };
880
881    let (bridge_host, server) =
882        resolve_revert_connection(&run, bridge_host.as_deref(), server.as_deref())?;
883    let driver = OpcDaDriver::connect(&bridge_host, &server).await?;
884
885    if is_table_output(output) {
886        println!(
887            "Reverting run {run_id}'s {response_level:?} PID write-back on tag '{}' to \
888             P={:.4} I={:.4} D={:.4}...",
889            run.loop_name, target.proportional, target.integral, target.derivative
890        );
891    }
892
893    let outcome = crate::commands::tune::write_pid_values(
894        pool,
895        run_id,
896        &driver,
897        p_tag,
898        i_tag,
899        d_tag,
900        response_level,
901        target,
902        WriteKind::Revert,
903        allow_uncertain_quality,
904    )
905    .await?;
906
907    match outcome {
908        crate::commands::tune::PidWriteOutcome::Written => {
909            tracing::info!(run_id, ?response_level, "PID revert succeeded");
910            match output {
911                OutputFormat::Table => {
912                    println!(
913                        "Reverted and confirmed run {run_id}'s {response_level:?} PID write-back."
914                    );
915                }
916                OutputFormat::Json => {
917                    let json = RevertJson {
918                        run_id,
919                        response_level,
920                        reverted_to: RevertedTargetJson {
921                            proportional: target.proportional,
922                            integral: target.integral,
923                            derivative: target.derivative,
924                        },
925                        success: true,
926                        error_message: None,
927                    };
928                    println!("{}", serde_json::to_string_pretty(&json)?);
929                }
930            }
931            Ok(())
932        }
933        crate::commands::tune::PidWriteOutcome::Failed { detail } => {
934            tracing::error!(run_id, ?response_level, error_message = %detail, "PID revert failed partway through; the loop may hold a mismatched set of PID constants -- see `history show` for the recorded partial state");
935            anyhow::bail!(
936                "revert failed partway through: {detail} (the loop may now hold a mismatched \
937                 set of PID constants -- see `history show {run_id}` for the recorded partial \
938                 state)"
939            );
940        }
941    }
942}
943
944#[cfg(test)]
945mod tests {
946    use super::*;
947    use bhtune_core::{ControllerType, DcsTemplate, LoopConfig, LoopTags, ProcessType};
948    use bhtune_db::models::{
949        NewTuneMvActuation, TemplateOrigin, TuneDriver, TuneRunInitialReadings,
950    };
951
952    fn sample_config() -> LoopConfig {
953        LoopConfig {
954            process_type: ProcessType::Flow,
955            controller_type: ControllerType::Pi,
956            relay_amp_percent: 10.0,
957            num_cycles_skip: 1,
958            num_cycles_count: 2,
959            noise_protection_secs: 3,
960            mrft_delay_secs: 0,
961        }
962    }
963
964    fn sample_template() -> DcsTemplate {
965        bhtune_core::built_in_templates().remove(0)
966    }
967
968    fn sample_tags() -> LoopTags {
969        LoopTags::derive_from_pv_tag("Unit1.LIC101.PV", &sample_template())
970    }
971
972    #[test]
973    fn row_and_connection_display_decisions_match_their_data() {
974        assert!(!has_rows::<u8>(&[]));
975        assert!(has_rows(&[1_u8]));
976        assert!(!has_recorded_connection(None, None));
977        assert!(has_recorded_connection(Some("Sim.Server"), None));
978        assert!(has_recorded_connection(None, Some("127.0.0.1:7600")));
979        assert!(has_recorded_connection(
980            Some("Sim.Server"),
981            Some("127.0.0.1:7600")
982        ));
983    }
984
985    #[test]
986    fn table_output_decision_matches_the_requested_format() {
987        assert!(is_table_output(OutputFormat::Table));
988        assert!(!is_table_output(OutputFormat::Json));
989    }
990
991    #[test]
992    fn sampling_adequacy_labels_cover_all_states() {
993        assert_eq!(
994            sampling_adequacy_label(SamplingAdequacy::Adequate),
995            "adequate"
996        );
997        assert_eq!(
998            sampling_adequacy_label(SamplingAdequacy::Marginal),
999            "marginal"
1000        );
1001        assert_eq!(
1002            sampling_adequacy_label(SamplingAdequacy::NotAssessed),
1003            "not assessed"
1004        );
1005    }
1006
1007    #[test]
1008    fn fmt_opt_f32_formats_values_and_missing_fields() {
1009        assert_eq!(fmt_opt_f32(Some(1.23456)), "1.2346");
1010        assert_eq!(fmt_opt_f32(Some(-0.5)), "-0.5000");
1011        assert_eq!(fmt_opt_f32(None), "-");
1012    }
1013
1014    #[test]
1015    fn fmt_opt_f64_formats_values_and_missing_fields() {
1016        assert_eq!(fmt_opt_f64(Some(1.23456)), "1.235");
1017        assert_eq!(fmt_opt_f64(Some(-0.5)), "-0.500");
1018        assert_eq!(fmt_opt_f64(None), "-");
1019    }
1020
1021    #[tokio::test]
1022    async fn list_handles_an_empty_database() {
1023        let pool = bhtune_db::connect_in_memory().await.unwrap();
1024        list(&pool, None, 50, 0, OutputFormat::Table).await.unwrap();
1025        list(&pool, None, 50, 0, OutputFormat::Json).await.unwrap();
1026    }
1027
1028    #[tokio::test]
1029    async fn list_propagates_database_errors() {
1030        let pool = bhtune_db::connect_in_memory().await.unwrap();
1031        pool.close().await;
1032
1033        assert!(list(&pool, None, 50, 0, OutputFormat::Table).await.is_err());
1034    }
1035
1036    #[tokio::test]
1037    async fn list_and_show_reflect_a_real_run() {
1038        let pool = bhtune_db::connect_in_memory().await.unwrap();
1039        let now = chrono::Utc::now();
1040        let run = TuneRunRow::start(
1041            &pool,
1042            None,
1043            "Unit1.LIC101.PV",
1044            TuneDriver::Simulator,
1045            sample_config(),
1046            TemplateOrigin::Builtin,
1047            &sample_template(),
1048            &sample_tags(),
1049            now,
1050        )
1051        .await
1052        .unwrap();
1053
1054        TuneRunRow::record_initial_readings(
1055            &pool,
1056            run.id,
1057            TuneRunInitialReadings {
1058                pv_ini: 50.0,
1059                mv_ini: 50.0,
1060                mv_range_low: 0.0,
1061                mv_range_high: 100.0,
1062                pv_range_high: 100.0,
1063                pv_range_low: 0.0,
1064                controller_direction: bhtune_core::ControllerDirection::Reverse,
1065                mode_raw: Some("1".to_string()),
1066                mode_attribute_raw: None,
1067                setpoint_ini: Some(50.0),
1068            },
1069        )
1070        .await
1071        .unwrap();
1072
1073        TuneRunRow::record_timing_metrics(
1074            &pool,
1075            run.id,
1076            bhtune_db::models::TimingMetrics {
1077                basis: bhtune_db::models::TimingBasis::SimulatedFixedStep,
1078                requested_interval_ms: 5,
1079                sample_gap_count: 9,
1080                mean_sample_gap_ms: Some(5.0),
1081                max_sample_gap_ms: Some(5.0),
1082                missed_poll_opportunity_count: 0,
1083                measured_oscillation_period_ms: Some(50.0),
1084                approximate_samples_per_period: Some(10.0),
1085                sampling_adequacy: bhtune_db::models::SamplingAdequacy::Adequate,
1086                poll_latency: None,
1087            },
1088        )
1089        .await
1090        .unwrap();
1091
1092        TuneRunRow::complete(&pool, run.id, now).await.unwrap();
1093
1094        list(&pool, None, 50, 0, OutputFormat::Table).await.unwrap();
1095        list(
1096            &pool,
1097            Some(bhtune_db::models::TuneOutcome::Completed),
1098            50,
1099            0,
1100            OutputFormat::Table,
1101        )
1102        .await
1103        .unwrap();
1104        show(&pool, run.id, OutputFormat::Table).await.unwrap();
1105        // This run has recorded initial readings (unlike the other JSON-path fixtures in
1106        // this file), so this is the one call site that exercises `InitialReadingsJson`'s
1107        // conversion.
1108        show(&pool, run.id, OutputFormat::Json).await.unwrap();
1109    }
1110
1111    #[tokio::test]
1112    async fn show_errors_for_an_unknown_run() {
1113        let pool = bhtune_db::connect_in_memory().await.unwrap();
1114        let err = show(&pool, 999, OutputFormat::Table).await.unwrap_err();
1115        assert!(err.to_string().contains("999"));
1116    }
1117
1118    #[tokio::test]
1119    async fn show_handles_a_failed_run_with_no_initial_readings() {
1120        let pool = bhtune_db::connect_in_memory().await.unwrap();
1121        let now = chrono::Utc::now();
1122        let run = TuneRunRow::start(
1123            &pool,
1124            None,
1125            "Unit1.LIC101.PV",
1126            TuneDriver::Simulator,
1127            sample_config(),
1128            TemplateOrigin::Builtin,
1129            &sample_template(),
1130            &sample_tags(),
1131            now,
1132        )
1133        .await
1134        .unwrap();
1135        TuneRunRow::fail(&pool, run.id, now, "connection refused")
1136            .await
1137            .unwrap();
1138        show(&pool, run.id, OutputFormat::Table).await.unwrap();
1139        show(&pool, run.id, OutputFormat::Json).await.unwrap();
1140    }
1141
1142    #[tokio::test]
1143    async fn show_prints_incomplete_restore_status() {
1144        let pool = bhtune_db::connect_in_memory().await.unwrap();
1145        let now = chrono::Utc::now();
1146        let run = TuneRunRow::start(
1147            &pool,
1148            None,
1149            "Unit1.LIC101.PV",
1150            TuneDriver::Simulator,
1151            sample_config(),
1152            TemplateOrigin::Builtin,
1153            &sample_template(),
1154            &sample_tags(),
1155            now,
1156        )
1157        .await
1158        .unwrap();
1159        TuneRunRow::complete(&pool, run.id, now).await.unwrap();
1160        TuneRunRow::record_restore_status(
1161            &pool,
1162            run.id,
1163            bhtune_db::models::RestoreStatus::Incomplete,
1164            Some("MV: write failed"),
1165        )
1166        .await
1167        .unwrap();
1168
1169        show(&pool, run.id, OutputFormat::Table).await.unwrap();
1170    }
1171
1172    /// A run carrying at least one `TuneResultRow` and one `TuneWriteRow`, so `show`'s
1173    /// "Calculated results" and "PID write-back audit" print blocks (otherwise never
1174    /// exercised, since every other fixture in this file completes with no results/writes
1175    /// recorded) both execute.
1176    async fn run_with_results_and_writes() -> (SqlitePool, i64) {
1177        let pool = bhtune_db::connect_in_memory().await.unwrap();
1178        let now = chrono::Utc::now();
1179        let run = TuneRunRow::start(
1180            &pool,
1181            None,
1182            "Unit1.LIC101.PV",
1183            TuneDriver::Opcda,
1184            sample_config(),
1185            TemplateOrigin::Builtin,
1186            &sample_template(),
1187            &sample_tags(),
1188            now,
1189        )
1190        .await
1191        .unwrap();
1192        // Records a real connection so `show`'s "Connection:" table line and its JSON
1193        // `opc_server`/`bridge_host` fields both get exercised here, rather than only ever
1194        // seeing the `None`/`None` simulator case elsewhere in this file.
1195        TuneRunRow::record_connection(
1196            &pool,
1197            run.id,
1198            Some("Kepware.KEPServerEX.V6"),
1199            Some("127.0.0.1:7600"),
1200            "{}",
1201        )
1202        .await
1203        .unwrap();
1204        TuneRunRow::complete(&pool, run.id, now).await.unwrap();
1205
1206        TuneResultRow::insert(
1207            &pool,
1208            &TuneResultRow {
1209                id: 0,
1210                run_id: run.id,
1211                response_level: bhtune_core::ResponseLevel::Moderate,
1212                kp: Some(1.5),
1213                ti_minutes: Some(0.7),
1214                td_minutes: Some(0.15),
1215                proportional: Some(12.0),
1216                integral: Some(2.5),
1217                derivative: Some(0.6),
1218                status: bhtune_core::TuningResultStatus::Valid,
1219                invalid_reason: None,
1220            },
1221        )
1222        .await
1223        .unwrap();
1224
1225        let mut successful =
1226            bhtune_db::models::NewTuneWrite::new(bhtune_core::ResponseLevel::Moderate, now);
1227        successful.previous = Some(bhtune_db::models::WriteReadback {
1228            proportional: 10.0,
1229            integral: 2.0,
1230            derivative: 0.5,
1231        });
1232        successful.proportional_written = Some(12.0);
1233        successful.integral_written = Some(2.5);
1234        successful.derivative_written = Some(0.6);
1235        successful.proportional_readback = Some(12.0);
1236        successful.integral_readback = Some(2.5);
1237        successful.derivative_readback = Some(0.6);
1238        successful.success = true;
1239        TuneWriteRow::insert(&pool, run.id, successful)
1240            .await
1241            .unwrap();
1242
1243        let mut failed =
1244            bhtune_db::models::NewTuneWrite::new(bhtune_core::ResponseLevel::Moderate, now);
1245        failed.previous = Some(bhtune_db::models::WriteReadback {
1246            proportional: 10.0,
1247            integral: 2.0,
1248            derivative: 0.5,
1249        });
1250        failed.proportional_written = Some(12.0);
1251        failed.error_message = Some("mock failure".to_string());
1252        failed.rollback_state = Some(bhtune_db::models::RollbackState::Failed);
1253        failed.rollback_error = Some("mock rollback failure".to_string());
1254        TuneWriteRow::insert(&pool, run.id, failed).await.unwrap();
1255
1256        let confirmed_actuation = TuneMvActuationRow::insert_pending(
1257            &pool,
1258            run.id,
1259            NewTuneMvActuation {
1260                sequence: 0,
1261                kind: MvActuationKind::Relay,
1262                commanded_at: now,
1263                target_mv: 55.0,
1264                previous_commanded_mv: Some(50.0),
1265                tolerance: 0.5,
1266                confirmation_due_at: now + chrono::Duration::seconds(4),
1267            },
1268        )
1269        .await
1270        .unwrap();
1271        TuneMvActuationRow::record_final_observation(
1272            &pool,
1273            confirmed_actuation.id,
1274            now + chrono::Duration::seconds(1),
1275            Some(55.0),
1276            Some(SampleQuality::Good),
1277            MvActuationStatus::Confirmed,
1278            None,
1279        )
1280        .await
1281        .unwrap();
1282
1283        let superseded_actuation = TuneMvActuationRow::insert_pending(
1284            &pool,
1285            run.id,
1286            NewTuneMvActuation {
1287                sequence: 1,
1288                kind: MvActuationKind::Restore,
1289                commanded_at: now + chrono::Duration::seconds(5),
1290                target_mv: 50.0,
1291                previous_commanded_mv: Some(55.0),
1292                tolerance: 0.5,
1293                confirmation_due_at: now + chrono::Duration::seconds(9),
1294            },
1295        )
1296        .await
1297        .unwrap();
1298        TuneMvActuationRow::finalize(
1299            &pool,
1300            superseded_actuation.id,
1301            MvActuationStatus::Superseded,
1302            Some("restore took over confirmation"),
1303        )
1304        .await
1305        .unwrap();
1306
1307        (pool, run.id)
1308    }
1309
1310    #[tokio::test]
1311    async fn show_prints_calculated_results_and_write_back_audit_rows() {
1312        let (pool, run_id) = run_with_results_and_writes().await;
1313        show(&pool, run_id, OutputFormat::Table).await.unwrap();
1314    }
1315
1316    #[tokio::test]
1317    async fn show_prints_timing_metrics_when_present() {
1318        let (pool, run_id) = run_with_results_and_writes().await;
1319        TuneRunRow::record_timing_metrics(
1320            &pool,
1321            run_id,
1322            bhtune_db::models::TimingMetrics {
1323                basis: bhtune_db::models::TimingBasis::LiveMonotonic,
1324                requested_interval_ms: 800,
1325                sample_gap_count: 2,
1326                mean_sample_gap_ms: Some(900.0),
1327                max_sample_gap_ms: Some(1_200.0),
1328                missed_poll_opportunity_count: 1,
1329                measured_oscillation_period_ms: Some(4_800.0),
1330                approximate_samples_per_period: Some(5.33),
1331                sampling_adequacy: bhtune_db::models::SamplingAdequacy::Marginal,
1332                poll_latency: Some(bhtune_db::models::PollLatencyMetrics {
1333                    pv_read: bhtune_db::models::TimingSummary {
1334                        count: 2,
1335                        mean_ms: Some(11.0),
1336                        max_ms: Some(15.0),
1337                    },
1338                    mv_write: bhtune_db::models::TimingSummary {
1339                        count: 1,
1340                        mean_ms: Some(20.0),
1341                        max_ms: Some(20.0),
1342                    },
1343                    mv_verification: bhtune_db::models::TimingSummary {
1344                        count: 1,
1345                        mean_ms: Some(30.0),
1346                        max_ms: Some(30.0),
1347                    },
1348                    sample_persist: bhtune_db::models::TimingSummary {
1349                        count: 2,
1350                        mean_ms: Some(4.0),
1351                        max_ms: Some(5.0),
1352                    },
1353                    tick_work: bhtune_db::models::TimingSummary {
1354                        count: 2,
1355                        mean_ms: Some(70.0),
1356                        max_ms: Some(75.0),
1357                    },
1358                }),
1359            },
1360        )
1361        .await
1362        .unwrap();
1363        show(&pool, run_id, OutputFormat::Table).await.unwrap();
1364    }
1365
1366    #[tokio::test]
1367    async fn list_output_json_is_valid_json_with_the_expected_shape() {
1368        let (pool, _run_id) = run_with_results_and_writes().await;
1369        // Can't easily capture stdout here, so this test's main job is proving the JSON
1370        // path doesn't panic/error across every branch -- the DTOs' field shapes are a
1371        // straightforward 1:1 projection of already-tested `bhtune-db` row structs.
1372        list(&pool, None, 50, 0, OutputFormat::Json).await.unwrap();
1373        list(
1374            &pool,
1375            Some(bhtune_db::models::TuneOutcome::Completed),
1376            50,
1377            0,
1378            OutputFormat::Json,
1379        )
1380        .await
1381        .unwrap();
1382    }
1383
1384    #[tokio::test]
1385    async fn show_output_json_covers_readings_results_and_writes() {
1386        let (pool, run_id) = run_with_results_and_writes().await;
1387        show(&pool, run_id, OutputFormat::Json).await.unwrap();
1388    }
1389
1390    #[tokio::test]
1391    async fn run_dispatches_list_and_show_in_both_output_formats() {
1392        let (pool, run_id) = run_with_results_and_writes().await;
1393        let config = crate::config::BhtuneConfig::default();
1394        run(
1395            &pool,
1396            HistoryCommand::List {
1397                outcome: None,
1398                limit: 50,
1399                offset: 0,
1400                output: OutputFormat::Table,
1401            },
1402            &config,
1403        )
1404        .await
1405        .unwrap();
1406        run(
1407            &pool,
1408            HistoryCommand::List {
1409                outcome: None,
1410                limit: 50,
1411                offset: 0,
1412                output: OutputFormat::Json,
1413            },
1414            &config,
1415        )
1416        .await
1417        .unwrap();
1418        run(
1419            &pool,
1420            HistoryCommand::Show {
1421                run_id,
1422                output: OutputFormat::Table,
1423            },
1424            &config,
1425        )
1426        .await
1427        .unwrap();
1428        run(
1429            &pool,
1430            HistoryCommand::Show {
1431                run_id,
1432                output: OutputFormat::Json,
1433            },
1434            &config,
1435        )
1436        .await
1437        .unwrap();
1438        run(
1439            &pool,
1440            HistoryCommand::Prune {
1441                older_than_days: Some(1),
1442                dry_run: true,
1443                output: OutputFormat::Table,
1444            },
1445            &crate::config::BhtuneConfig {
1446                retention_days: Some(1),
1447                ..crate::config::BhtuneConfig::default()
1448            },
1449        )
1450        .await
1451        .unwrap();
1452    }
1453
1454    #[tokio::test]
1455    async fn run_dispatches_revert_and_surfaces_its_error() {
1456        // No live gateway is running, so `revert` fails while connecting -- but this still
1457        // proves `run` actually dispatches `HistoryCommand::Revert` to the `revert` function
1458        // rather than, say, silently no-op'ing. `revert`'s own success/failure/validation
1459        // behavior is covered directly by the `revert_*` tests below. Flags are omitted
1460        // (`None`/`None`) so the connection resolves from the run's own recorded values
1461        // (`run_with_results_and_writes` now records one) rather than being rejected earlier
1462        // by `resolve_revert_connection`'s contradiction check.
1463        let (pool, run_id) = run_with_results_and_writes().await;
1464        let config = crate::config::BhtuneConfig::default();
1465        let err = run(
1466            &pool,
1467            HistoryCommand::Revert {
1468                run_id,
1469                bridge_host: None,
1470                server: None,
1471                yes: true,
1472                output: OutputFormat::Table,
1473            },
1474            &config,
1475        )
1476        .await
1477        .unwrap_err();
1478        assert!(!err.to_string().is_empty());
1479    }
1480
1481    /// Starts an `Opcda`-driver run (using the sample template/tags, which have PID
1482    /// constant tags configured) with `record_connection` already called for it -- exactly
1483    /// what `prepare` does for a real run (`db-run-request-snapshot`) -- so `revert`'s own
1484    /// connection-resolution logic has a stored value to resolve against, matching
1485    /// production shape rather than the pre-`db-run-request-snapshot` gap where a run had no
1486    /// recorded connection at all. Returns it without recording any write-back yet -- each
1487    /// `revert_*` test below inserts whatever `TuneWriteRow` fixture its scenario needs.
1488    async fn opcda_run_with_no_writes(bridge_host: &str, server: &str) -> (SqlitePool, i64) {
1489        let pool = bhtune_db::connect_in_memory().await.unwrap();
1490        let now = chrono::Utc::now();
1491        let run = TuneRunRow::start(
1492            &pool,
1493            None,
1494            "Unit1.LIC101.PV",
1495            TuneDriver::Opcda,
1496            sample_config(),
1497            TemplateOrigin::Builtin,
1498            &sample_template(),
1499            &sample_tags(),
1500            now,
1501        )
1502        .await
1503        .unwrap();
1504        TuneRunRow::record_connection(&pool, run.id, Some(server), Some(bridge_host), "{}")
1505            .await
1506            .unwrap();
1507        (pool, run.id)
1508    }
1509
1510    #[tokio::test]
1511    async fn revert_errors_when_no_such_run_exists() {
1512        let pool = bhtune_db::connect_in_memory().await.unwrap();
1513        let err = revert(
1514            &pool,
1515            999,
1516            None,
1517            None,
1518            true,
1519            OutputFormat::Table,
1520            &crate::config::BhtuneConfig::default(),
1521        )
1522        .await
1523        .unwrap_err();
1524        assert!(err.to_string().contains("no run with id 999"));
1525    }
1526
1527    #[tokio::test]
1528    async fn revert_errors_when_the_run_did_not_use_the_opcda_driver() {
1529        let pool = bhtune_db::connect_in_memory().await.unwrap();
1530        let now = chrono::Utc::now();
1531        let run = TuneRunRow::start(
1532            &pool,
1533            None,
1534            "Unit1.LIC101.PV",
1535            TuneDriver::Simulator,
1536            sample_config(),
1537            TemplateOrigin::Builtin,
1538            &sample_template(),
1539            &sample_tags(),
1540            now,
1541        )
1542        .await
1543        .unwrap();
1544        let err = revert(
1545            &pool,
1546            run.id,
1547            None,
1548            None,
1549            true,
1550            OutputFormat::Table,
1551            &crate::config::BhtuneConfig::default(),
1552        )
1553        .await
1554        .unwrap_err();
1555        assert!(err.to_string().contains("Simulator"));
1556    }
1557
1558    #[tokio::test]
1559    async fn revert_errors_when_no_write_back_is_recorded() {
1560        let (pool, run_id) = opcda_run_with_no_writes("bridge:1", "Sim.Server").await;
1561        let err = revert(
1562            &pool,
1563            run_id,
1564            None,
1565            None,
1566            true,
1567            OutputFormat::Table,
1568            &crate::config::BhtuneConfig::default(),
1569        )
1570        .await
1571        .unwrap_err();
1572        assert!(err.to_string().contains("no recorded PID write-back"));
1573    }
1574
1575    #[tokio::test]
1576    async fn revert_errors_when_the_original_writes_pre_read_failed() {
1577        let (pool, run_id) = opcda_run_with_no_writes("bridge:1", "Sim.Server").await;
1578        let now = chrono::Utc::now();
1579        let mut failed_pre_read =
1580            bhtune_db::models::NewTuneWrite::new(bhtune_core::ResponseLevel::Moderate, now);
1581        failed_pre_read.error_message = Some("pre-read of Proportional tag 'X' failed".to_string());
1582        TuneWriteRow::insert(&pool, run_id, failed_pre_read)
1583            .await
1584            .unwrap();
1585
1586        let err = revert(
1587            &pool,
1588            run_id,
1589            None,
1590            None,
1591            true,
1592            OutputFormat::Table,
1593            &crate::config::BhtuneConfig::default(),
1594        )
1595        .await
1596        .unwrap_err();
1597        assert!(err.to_string().contains("nothing to revert to"));
1598    }
1599
1600    #[tokio::test]
1601    async fn revert_errors_when_yes_is_not_set() {
1602        let (pool, run_id) = opcda_run_with_no_writes("bridge:1", "Sim.Server").await;
1603        let now = chrono::Utc::now();
1604        let mut successful =
1605            bhtune_db::models::NewTuneWrite::new(bhtune_core::ResponseLevel::Moderate, now);
1606        successful.previous = Some(bhtune_db::models::WriteReadback {
1607            proportional: 10.0,
1608            integral: 2.0,
1609            derivative: 0.5,
1610        });
1611        successful.success = true;
1612        TuneWriteRow::insert(&pool, run_id, successful)
1613            .await
1614            .unwrap();
1615
1616        let err = revert(
1617            &pool,
1618            run_id,
1619            None,
1620            None,
1621            false,
1622            OutputFormat::Table,
1623            &crate::config::BhtuneConfig::default(),
1624        )
1625        .await
1626        .unwrap_err();
1627        assert!(err.to_string().contains("--yes"));
1628    }
1629
1630    #[tokio::test]
1631    async fn revert_errors_when_the_snapshotted_tags_have_no_pid_constant_tags() {
1632        let pool = bhtune_db::connect_in_memory().await.unwrap();
1633        let now = chrono::Utc::now();
1634        let mut tags = sample_tags();
1635        tags.proportional_constant = None;
1636        let run = TuneRunRow::start(
1637            &pool,
1638            None,
1639            "Unit1.LIC101.PV",
1640            TuneDriver::Opcda,
1641            sample_config(),
1642            TemplateOrigin::Builtin,
1643            &sample_template(),
1644            &tags,
1645            now,
1646        )
1647        .await
1648        .unwrap();
1649        TuneRunRow::record_connection(&pool, run.id, Some("Sim.Server"), Some("bridge:1"), "{}")
1650            .await
1651            .unwrap();
1652        let mut successful =
1653            bhtune_db::models::NewTuneWrite::new(bhtune_core::ResponseLevel::Moderate, now);
1654        successful.previous = Some(bhtune_db::models::WriteReadback {
1655            proportional: 10.0,
1656            integral: 2.0,
1657            derivative: 0.5,
1658        });
1659        successful.success = true;
1660        TuneWriteRow::insert(&pool, run.id, successful)
1661            .await
1662            .unwrap();
1663
1664        let err = revert(
1665            &pool,
1666            run.id,
1667            None,
1668            None,
1669            true,
1670            OutputFormat::Table,
1671            &crate::config::BhtuneConfig::default(),
1672        )
1673        .await
1674        .unwrap_err();
1675        assert!(err.to_string().contains("no PID constant tags"));
1676    }
1677
1678    #[tokio::test]
1679    async fn revert_errors_when_the_driver_connection_fails() {
1680        // Port 1 is a privileged/unlikely-bound port; connecting should fail promptly,
1681        // proving every validation step above passed and `revert` genuinely reached the
1682        // connect step, resolving the connection from the run's own recorded values (no
1683        // explicit `--bridge-host`/`--server` flags passed at all here).
1684        let (pool, run_id) = opcda_run_with_no_writes("127.0.0.1:1", "Sim.Server").await;
1685        let now = chrono::Utc::now();
1686        let mut successful =
1687            bhtune_db::models::NewTuneWrite::new(bhtune_core::ResponseLevel::Moderate, now);
1688        successful.previous = Some(bhtune_db::models::WriteReadback {
1689            proportional: 10.0,
1690            integral: 2.0,
1691            derivative: 0.5,
1692        });
1693        successful.success = true;
1694        TuneWriteRow::insert(&pool, run_id, successful)
1695            .await
1696            .unwrap();
1697
1698        let err = revert(
1699            &pool,
1700            run_id,
1701            None,
1702            None,
1703            true,
1704            OutputFormat::Table,
1705            &crate::config::BhtuneConfig::default(),
1706        )
1707        .await
1708        .unwrap_err();
1709        assert!(!err.to_string().is_empty());
1710    }
1711
1712    #[tokio::test]
1713    async fn revert_errors_when_the_run_has_no_recorded_connection() {
1714        // A run created directly via `TuneRunRow::start` without ever calling
1715        // `record_connection` -- shouldn't happen for a real run created through
1716        // `prepare`/the HTTP API, but `revert` must still refuse loudly rather than guess
1717        // which OPC server/gateway to target (`db-run-request-snapshot`).
1718        let pool = bhtune_db::connect_in_memory().await.unwrap();
1719        let now = chrono::Utc::now();
1720        let run = TuneRunRow::start(
1721            &pool,
1722            None,
1723            "Unit1.LIC101.PV",
1724            TuneDriver::Opcda,
1725            sample_config(),
1726            TemplateOrigin::Builtin,
1727            &sample_template(),
1728            &sample_tags(),
1729            now,
1730        )
1731        .await
1732        .unwrap();
1733        let mut successful =
1734            bhtune_db::models::NewTuneWrite::new(bhtune_core::ResponseLevel::Moderate, now);
1735        successful.previous = Some(bhtune_db::models::WriteReadback {
1736            proportional: 10.0,
1737            integral: 2.0,
1738            derivative: 0.5,
1739        });
1740        successful.success = true;
1741        TuneWriteRow::insert(&pool, run.id, successful)
1742            .await
1743            .unwrap();
1744
1745        let err = revert(
1746            &pool,
1747            run.id,
1748            None,
1749            None,
1750            true,
1751            OutputFormat::Table,
1752            &crate::config::BhtuneConfig::default(),
1753        )
1754        .await
1755        .unwrap_err();
1756        assert!(err.to_string().contains("recorded OPC server is missing"));
1757    }
1758
1759    #[tokio::test]
1760    async fn resolve_revert_connection_rejects_a_missing_recorded_bridge_host() {
1761        let (pool, run_id) = opcda_run_with_no_writes("bridge:1", "Sim.Server").await;
1762        let mut run = TuneRunRow::get(&pool, run_id).await.unwrap().unwrap();
1763        run.bridge_host = None;
1764
1765        let err = resolve_revert_connection(&run, None, None).unwrap_err();
1766        assert!(err.to_string().contains("recorded bridge host is missing"));
1767    }
1768
1769    #[tokio::test]
1770    async fn revert_errors_when_an_explicit_server_flag_contradicts_the_recorded_one() {
1771        let (pool, run_id) = opcda_run_with_no_writes("bridge:1", "Sim.Server").await;
1772        let now = chrono::Utc::now();
1773        let mut successful =
1774            bhtune_db::models::NewTuneWrite::new(bhtune_core::ResponseLevel::Moderate, now);
1775        successful.previous = Some(bhtune_db::models::WriteReadback {
1776            proportional: 10.0,
1777            integral: 2.0,
1778            derivative: 0.5,
1779        });
1780        successful.success = true;
1781        TuneWriteRow::insert(&pool, run_id, successful)
1782            .await
1783            .unwrap();
1784
1785        let err = revert(
1786            &pool,
1787            run_id,
1788            None,
1789            Some("Different.Server".to_string()),
1790            true,
1791            OutputFormat::Table,
1792            &crate::config::BhtuneConfig::default(),
1793        )
1794        .await
1795        .unwrap_err();
1796        let message = err.to_string();
1797        assert!(message.contains("contradicts"));
1798        assert!(message.contains("Sim.Server"));
1799    }
1800
1801    #[tokio::test]
1802    async fn revert_errors_when_an_explicit_bridge_host_flag_contradicts_the_recorded_one() {
1803        let (pool, run_id) = opcda_run_with_no_writes("bridge:1", "Sim.Server").await;
1804        let now = chrono::Utc::now();
1805        let mut successful =
1806            bhtune_db::models::NewTuneWrite::new(bhtune_core::ResponseLevel::Moderate, now);
1807        successful.previous = Some(bhtune_db::models::WriteReadback {
1808            proportional: 10.0,
1809            integral: 2.0,
1810            derivative: 0.5,
1811        });
1812        successful.success = true;
1813        TuneWriteRow::insert(&pool, run_id, successful)
1814            .await
1815            .unwrap();
1816
1817        let err = revert(
1818            &pool,
1819            run_id,
1820            Some("different-bridge:2".to_string()),
1821            None,
1822            true,
1823            OutputFormat::Table,
1824            &crate::config::BhtuneConfig::default(),
1825        )
1826        .await
1827        .unwrap_err();
1828        let message = err.to_string();
1829        assert!(message.contains("contradicts"));
1830        assert!(message.contains("bridge:1"));
1831    }
1832
1833    #[tokio::test]
1834    async fn revert_succeeds_and_records_a_revert_kind_write() {
1835        use crate::test_support::{MockBridgeService, start_mock_server};
1836        use opcda_bridge_proto::bridge::{ReadResponse, TagValue as ProtoTagValue, WriteResponse};
1837
1838        // Every read (both the live pre-read and every write's confirmation readback)
1839        // returns this same fixed "10.0"/Good response regardless of which tag was
1840        // requested -- so the fixture's recorded `previous` values are all set to 10.0 too,
1841        // ensuring `write_and_verify_pid_value`'s tolerance check always sees a matching
1842        // readback no matter which constant is being reverted.
1843        let (host, server) = start_mock_server(MockBridgeService {
1844            read_response: ReadResponse {
1845                values: vec![ProtoTagValue {
1846                    tag_id: "ignored".to_string(),
1847                    value: "10.0".to_string(),
1848                    quality: "Good".to_string(),
1849                    timestamp: "2024-01-15 10:23:45".to_string(),
1850                }],
1851            },
1852            write_response: WriteResponse {
1853                tag_id: "ignored".to_string(),
1854                success: true,
1855                error: None,
1856            },
1857            ..Default::default()
1858        })
1859        .await;
1860
1861        let (pool, run_id) = opcda_run_with_no_writes(&host, "Sim.Server").await;
1862        let now = chrono::Utc::now();
1863        let mut successful =
1864            bhtune_db::models::NewTuneWrite::new(bhtune_core::ResponseLevel::Moderate, now);
1865        successful.previous = Some(bhtune_db::models::WriteReadback {
1866            proportional: 10.0,
1867            integral: 10.0,
1868            derivative: 10.0,
1869        });
1870        successful.success = true;
1871        TuneWriteRow::insert(&pool, run_id, successful)
1872            .await
1873            .unwrap();
1874
1875        // Explicit `--bridge-host`/`--server` flags are passed here too (matching the
1876        // recorded connection exactly), deliberately exercising the cross-check-passes path
1877        // rather than the "omit both, use the recorded value" path already covered by
1878        // `revert_errors_when_the_driver_connection_fails` above.
1879        revert(
1880            &pool,
1881            run_id,
1882            Some(host),
1883            Some("Sim.Server".to_string()),
1884            true,
1885            OutputFormat::Table,
1886            &crate::config::BhtuneConfig::default(),
1887        )
1888        .await
1889        .unwrap();
1890
1891        let writes = TuneWriteRow::list_for_run(&pool, run_id).await.unwrap();
1892        let revert_row = writes.iter().find(|w| w.kind == WriteKind::Revert).unwrap();
1893        assert!(revert_row.success);
1894        assert_eq!(revert_row.proportional_written, Some(10.0));
1895        assert_eq!(revert_row.integral_written, Some(10.0));
1896        assert_eq!(revert_row.derivative_written, Some(10.0));
1897        assert_eq!(revert_row.proportional_readback, Some(10.0));
1898        assert_eq!(revert_row.integral_readback, Some(10.0));
1899        assert_eq!(revert_row.derivative_readback, Some(10.0));
1900        // Reverts never chain a nested rollback-of-a-revert.
1901        assert_eq!(revert_row.rollback_state, None);
1902        // The pre-read of the *live* current value (also fed by the fixed mock response)
1903        // becomes this revert row's own `previous`, so a second revert could undo it too.
1904        assert_eq!(
1905            revert_row.previous,
1906            Some(bhtune_db::models::WriteReadback {
1907                proportional: 10.0,
1908                integral: 10.0,
1909                derivative: 10.0,
1910            })
1911        );
1912
1913        server.shutdown().await;
1914    }
1915
1916    #[tokio::test]
1917    async fn revert_records_a_failed_audit_row_when_a_later_constant_fails_verification() {
1918        use crate::test_support::{MockBridgeService, start_mock_server};
1919        use opcda_bridge_proto::bridge::{ReadResponse, TagValue as ProtoTagValue, WriteResponse};
1920
1921        // Calls 1-3: the live pre-read of P/I/D (all succeed). Call 4: Proportional's
1922        // post-write verification readback (succeeds). Call 5: Integral's post-write
1923        // verification readback -- fails, per `failing_read_from_call(5)`. Derivative is
1924        // never attempted, since the loop breaks on the first failure.
1925        let (host, server) = start_mock_server(
1926            MockBridgeService {
1927                read_response: ReadResponse {
1928                    values: vec![ProtoTagValue {
1929                        tag_id: "ignored".to_string(),
1930                        value: "10.0".to_string(),
1931                        quality: "Good".to_string(),
1932                        timestamp: "2024-01-15 10:23:45".to_string(),
1933                    }],
1934                },
1935                write_response: WriteResponse {
1936                    tag_id: "ignored".to_string(),
1937                    success: true,
1938                    error: None,
1939                },
1940                ..Default::default()
1941            }
1942            .failing_read_from_call(5),
1943        )
1944        .await;
1945
1946        let (pool, run_id) = opcda_run_with_no_writes(&host, "Sim.Server").await;
1947        let now = chrono::Utc::now();
1948        let mut successful =
1949            bhtune_db::models::NewTuneWrite::new(bhtune_core::ResponseLevel::Moderate, now);
1950        successful.previous = Some(bhtune_db::models::WriteReadback {
1951            proportional: 10.0,
1952            integral: 10.0,
1953            derivative: 10.0,
1954        });
1955        successful.success = true;
1956        TuneWriteRow::insert(&pool, run_id, successful)
1957            .await
1958            .unwrap();
1959
1960        let err = revert(
1961            &pool,
1962            run_id,
1963            None,
1964            None,
1965            true,
1966            OutputFormat::Table,
1967            &crate::config::BhtuneConfig::default(),
1968        )
1969        .await
1970        .unwrap_err();
1971        assert!(err.to_string().contains("revert failed partway through"));
1972
1973        let writes = TuneWriteRow::list_for_run(&pool, run_id).await.unwrap();
1974        let revert_row = writes.iter().find(|w| w.kind == WriteKind::Revert).unwrap();
1975        assert!(!revert_row.success);
1976        assert!(
1977            revert_row
1978                .error_message
1979                .as_ref()
1980                .unwrap()
1981                .contains("Integral")
1982        );
1983        assert_eq!(revert_row.proportional_written, Some(10.0));
1984        assert_eq!(revert_row.proportional_readback, Some(10.0));
1985        assert_eq!(revert_row.integral_written, Some(10.0));
1986        assert_eq!(revert_row.integral_readback, None);
1987        assert_eq!(revert_row.derivative_written, None);
1988        assert_eq!(revert_row.derivative_readback, None);
1989        assert_eq!(revert_row.rollback_state, None);
1990
1991        server.shutdown().await;
1992    }
1993
1994    #[tokio::test]
1995    async fn revert_succeeds_with_json_output_format() {
1996        use crate::test_support::{MockBridgeService, start_mock_server};
1997        use opcda_bridge_proto::bridge::{ReadResponse, TagValue as ProtoTagValue, WriteResponse};
1998
1999        // This isn't a substitute for a real stdout-capture test proving JSON mode never
2000        // interleaves prose ahead of the final object (that end-to-end contract belongs to
2001        // `safety-json-contract`'s subprocess test, across every subcommand at once) -- it
2002        // only proves `revert`'s `OutputFormat::Json` branch itself runs to completion
2003        // without erroring, i.e. that constructing and serializing `RevertJson` from a real
2004        // successful revert actually works, which the Table-mode test above never exercises.
2005        let (host, server) = start_mock_server(MockBridgeService {
2006            read_response: ReadResponse {
2007                values: vec![ProtoTagValue {
2008                    tag_id: "ignored".to_string(),
2009                    value: "10.0".to_string(),
2010                    quality: "Good".to_string(),
2011                    timestamp: "2024-01-15 10:23:45".to_string(),
2012                }],
2013            },
2014            write_response: WriteResponse {
2015                tag_id: "ignored".to_string(),
2016                success: true,
2017                error: None,
2018            },
2019            ..Default::default()
2020        })
2021        .await;
2022
2023        let (pool, run_id) = opcda_run_with_no_writes(&host, "Sim.Server").await;
2024        let now = chrono::Utc::now();
2025        let mut successful =
2026            bhtune_db::models::NewTuneWrite::new(bhtune_core::ResponseLevel::Moderate, now);
2027        successful.previous = Some(bhtune_db::models::WriteReadback {
2028            proportional: 10.0,
2029            integral: 10.0,
2030            derivative: 10.0,
2031        });
2032        successful.success = true;
2033        TuneWriteRow::insert(&pool, run_id, successful)
2034            .await
2035            .unwrap();
2036
2037        revert(
2038            &pool,
2039            run_id,
2040            None,
2041            None,
2042            true,
2043            OutputFormat::Json,
2044            &crate::config::BhtuneConfig::default(),
2045        )
2046        .await
2047        .unwrap();
2048
2049        server.shutdown().await;
2050    }
2051
2052    /// Starts a run with `started_at` set explicitly (unlike every other fixture in this
2053    /// file, which always uses `chrono::Utc::now()`) so `prune`'s tests can put a run
2054    /// unambiguously on either side of a retention cutoff.
2055    async fn start_run_at(pool: &SqlitePool, started_at: chrono::DateTime<chrono::Utc>) -> i64 {
2056        TuneRunRow::start(
2057            pool,
2058            None,
2059            "Unit1.LIC101.PV",
2060            TuneDriver::Simulator,
2061            sample_config(),
2062            TemplateOrigin::Builtin,
2063            &sample_template(),
2064            &sample_tags(),
2065            started_at,
2066        )
2067        .await
2068        .unwrap()
2069        .id
2070    }
2071
2072    #[tokio::test]
2073    async fn prune_errors_when_no_retention_policy_is_configured() {
2074        let pool = bhtune_db::connect_in_memory().await.unwrap();
2075        let config = crate::config::BhtuneConfig::default();
2076        let err = prune(&pool, &config, None, false, OutputFormat::Table)
2077            .await
2078            .unwrap_err();
2079        assert!(err.to_string().contains("no retention policy configured"));
2080    }
2081
2082    #[tokio::test]
2083    async fn prune_dry_run_reports_the_count_without_deleting_anything() {
2084        let pool = bhtune_db::connect_in_memory().await.unwrap();
2085        let now = chrono::Utc::now();
2086        let old_id = start_run_at(&pool, now - chrono::Duration::days(45)).await;
2087        let recent_id = start_run_at(&pool, now - chrono::Duration::days(1)).await;
2088        let config = crate::config::BhtuneConfig {
2089            retention_days: Some(30),
2090            ..Default::default()
2091        };
2092
2093        prune(&pool, &config, None, true, OutputFormat::Table)
2094            .await
2095            .unwrap();
2096        prune(&pool, &config, None, true, OutputFormat::Json)
2097            .await
2098            .unwrap();
2099
2100        // Nothing was actually deleted by either dry-run call.
2101        assert!(TuneRunRow::get(&pool, old_id).await.unwrap().is_some());
2102        assert!(TuneRunRow::get(&pool, recent_id).await.unwrap().is_some());
2103    }
2104
2105    #[tokio::test]
2106    async fn prune_deletes_matching_runs_when_not_a_dry_run() {
2107        let pool = bhtune_db::connect_in_memory().await.unwrap();
2108        let now = chrono::Utc::now();
2109        let old_id = start_run_at(&pool, now - chrono::Duration::days(45)).await;
2110        let recent_id = start_run_at(&pool, now - chrono::Duration::days(1)).await;
2111        let config = crate::config::BhtuneConfig {
2112            retention_days: Some(30),
2113            ..Default::default()
2114        };
2115
2116        prune(&pool, &config, None, false, OutputFormat::Table)
2117            .await
2118            .unwrap();
2119
2120        assert!(TuneRunRow::get(&pool, old_id).await.unwrap().is_none());
2121        assert!(TuneRunRow::get(&pool, recent_id).await.unwrap().is_some());
2122    }
2123
2124    #[tokio::test]
2125    async fn prune_older_than_days_overrides_the_configured_policy() {
2126        let pool = bhtune_db::connect_in_memory().await.unwrap();
2127        let now = chrono::Utc::now();
2128        // 45 days old: survives the configured 90-day policy, but not an ad-hoc
2129        // `--older-than-days 30` override.
2130        let run_id = start_run_at(&pool, now - chrono::Duration::days(45)).await;
2131        let config = crate::config::BhtuneConfig {
2132            retention_days: Some(90),
2133            ..Default::default()
2134        };
2135
2136        prune(&pool, &config, Some(30), false, OutputFormat::Table)
2137            .await
2138            .unwrap();
2139
2140        assert!(TuneRunRow::get(&pool, run_id).await.unwrap().is_none());
2141    }
2142
2143    #[tokio::test]
2144    async fn prune_json_output_is_a_single_parseable_object() {
2145        let pool = bhtune_db::connect_in_memory().await.unwrap();
2146        let config = crate::config::BhtuneConfig {
2147            retention_days: Some(30),
2148            ..Default::default()
2149        };
2150        // Only proves the `Json` branch runs to completion and produces a well-formed
2151        // `PruneJson` in both the dry-run and real-deletion cases -- the full
2152        // one-JSON-value-on-stdout contract across every subcommand belongs to
2153        // `safety-json-contract`'s dedicated subprocess test, not to this unit test.
2154        prune(&pool, &config, None, true, OutputFormat::Json)
2155            .await
2156            .unwrap();
2157        prune(&pool, &config, None, false, OutputFormat::Json)
2158            .await
2159            .unwrap();
2160    }
2161}