Skip to main content

futucli/cmd/
static_diag.rs

1use anyhow::{Context, Result};
2use futu_core::diagnostic::DiagnosticDecision;
3use serde::Serialize;
4use serde_json::Value;
5use tabled::Tabled;
6
7use crate::output::OutputFormat;
8
9#[derive(Debug, Clone, Serialize)]
10pub(crate) struct StaticStatusReport {
11    pub(crate) ready: bool,
12    pub(crate) reason: String,
13    pub(crate) action: String,
14    pub(crate) security_count: u64,
15    pub(crate) stock_list_readiness: String,
16    pub(crate) stock_list_readiness_state: String,
17    pub(crate) stock_list_first_sync_done: bool,
18    pub(crate) stock_list_converged: bool,
19    pub(crate) stock_list_sync_started_total: u64,
20    pub(crate) stock_list_sync_finished_total: u64,
21    pub(crate) stock_list_sync_failed_total: u64,
22    pub(crate) stock_list_sync_recoverable_retry_total: u64,
23    pub(crate) stock_list_zero_delta_total: u64,
24    pub(crate) stock_list_last_version: u64,
25    pub(crate) stock_list_last_total_stocks: u64,
26    pub(crate) stock_list_last_cached_count: u64,
27    #[serde(skip_serializing_if = "Option::is_none")]
28    pub(crate) stock_list_last_finished_ms: Option<u64>,
29    pub(crate) decisions: Vec<DiagnosticDecision>,
30    pub(crate) findings: Vec<StaticStatusFinding>,
31}
32
33#[derive(Debug, Clone, Serialize)]
34pub(crate) struct StaticStatusFinding {
35    pub(crate) level: &'static str,
36    pub(crate) message: String,
37}
38
39#[derive(Tabled)]
40struct StaticStatusRow {
41    field: String,
42    value: String,
43}
44
45pub async fn run_static_status(
46    rest_url: Option<&str>,
47    api_key: Option<&str>,
48    output: OutputFormat,
49) -> Result<()> {
50    let body = super::daemon::request(
51        reqwest::Method::GET,
52        "/api/admin/status",
53        rest_url,
54        api_key,
55        5,
56    )
57    .await?;
58    let value: Value = serde_json::from_str(&body).context("parse /api/admin/status JSON")?;
59    let report = summarize_static_status(&value)?;
60    print_static_status(&report, output)
61}
62
63pub(crate) fn summarize_static_status(status: &Value) -> Result<StaticStatusReport> {
64    let static_data = status
65        .get("cache")
66        .and_then(|v| v.get("static_data"))
67        .ok_or_else(|| anyhow::anyhow!("missing cache.static_data in /api/admin/status"))?;
68    let security_count = u64_field(static_data, "security_count")?;
69    let first_sync_done = bool_field(static_data, "stock_list_first_sync_done")?;
70    let converged = bool_field(static_data, "stock_list_converged")?;
71    let started_total = u64_field(static_data, "stock_list_sync_started_total")?;
72    let finished_total = u64_field(static_data, "stock_list_sync_finished_total")?;
73    let failed_total = u64_field(static_data, "stock_list_sync_failed_total")?;
74    let recoverable_retry_total =
75        optional_u64_field(static_data, "stock_list_sync_recoverable_retry_total");
76    let zero_delta_total = optional_u64_field(static_data, "stock_list_zero_delta_total");
77    let last_version = u64_field(static_data, "stock_list_last_version")?;
78    let last_total_stocks = u64_field(static_data, "stock_list_last_total_stocks")?;
79    let last_cached_count = u64_field(static_data, "stock_list_last_cached_count")?;
80    let last_finished_ms = static_data
81        .get("stock_list_last_finished_ms")
82        .and_then(Value::as_u64);
83    let readiness = static_data
84        .get("stock_list_readiness")
85        .and_then(Value::as_str)
86        .map(str::to_string)
87        .unwrap_or_else(|| {
88            derive_stock_list_readiness(
89                security_count,
90                first_sync_done,
91                converged,
92                started_total,
93                finished_total,
94                failed_total,
95                recoverable_retry_total,
96            )
97            .to_string()
98        });
99
100    let mut findings = Vec::new();
101    if readiness == "bootstrap_only" {
102        findings.push(StaticStatusFinding {
103            level: "INFO",
104            message: "static cache only has SQLite bootstrap rows; wait for backend stock-list sync before parity checks".to_string(),
105        });
106    }
107    if !first_sync_done {
108        findings.push(StaticStatusFinding {
109            level: "WARN",
110            message: "stock-list first sync not finished yet".to_string(),
111        });
112    } else if !converged {
113        findings.push(StaticStatusFinding {
114            level: "INFO",
115            message: "stock-list first sync finished but not converged yet; wait for a zero-delta sync before static parity checks".to_string(),
116        });
117    }
118    if started_total > finished_total {
119        findings.push(StaticStatusFinding {
120            level: "INFO",
121            message: format!(
122                "stock-list sync in progress or pending: started_total={started_total}, finished_total={finished_total}"
123            ),
124        });
125    }
126    if failed_total > 0 {
127        findings.push(StaticStatusFinding {
128            level: "WARN",
129            message: format!("stock-list sync has failed_total={failed_total}"),
130        });
131    }
132    if recoverable_retry_total > 0 {
133        findings.push(StaticStatusFinding {
134            level: "INFO",
135            message: format!(
136                "stock-list sync had recoverable checksum rollback/retry total={recoverable_retry_total}"
137            ),
138        });
139    }
140    if security_count == 0 {
141        findings.push(StaticStatusFinding {
142            level: "WARN",
143            message: "static security cache is empty".to_string(),
144        });
145    }
146    let (reason, action) = static_status_reason_action(
147        &readiness,
148        security_count,
149        first_sync_done,
150        converged,
151        started_total,
152        finished_total,
153        failed_total,
154        recoverable_retry_total,
155    );
156    let readiness_state = stock_list_readiness_state(&readiness).to_string();
157    let ready = first_sync_done && converged && security_count > 0;
158    let decision = if ready {
159        DiagnosticDecision::allowed(
160            "stock_list_sync",
161            reason.clone(),
162            "cache.static_data",
163            readiness_state.clone(),
164        )
165    } else {
166        DiagnosticDecision::blocked(
167            "stock_list_sync",
168            reason.clone(),
169            action.clone(),
170            "cache.static_data",
171            readiness_state.clone(),
172        )
173    };
174
175    if findings.is_empty() {
176        findings.push(StaticStatusFinding {
177            level: "OK",
178            message: "static cache is ready".to_string(),
179        });
180    }
181
182    Ok(StaticStatusReport {
183        ready,
184        reason,
185        action,
186        security_count,
187        stock_list_readiness: readiness,
188        stock_list_readiness_state: readiness_state,
189        stock_list_first_sync_done: first_sync_done,
190        stock_list_converged: converged,
191        stock_list_sync_started_total: started_total,
192        stock_list_sync_finished_total: finished_total,
193        stock_list_sync_failed_total: failed_total,
194        stock_list_sync_recoverable_retry_total: recoverable_retry_total,
195        stock_list_zero_delta_total: zero_delta_total,
196        stock_list_last_version: last_version,
197        stock_list_last_total_stocks: last_total_stocks,
198        stock_list_last_cached_count: last_cached_count,
199        stock_list_last_finished_ms: last_finished_ms,
200        decisions: vec![decision],
201        findings,
202    })
203}
204
205fn stock_list_readiness_state(readiness: &str) -> &'static str {
206    match readiness {
207        "converged" => "ready",
208        "converged_after_recoverable_retry" | "converged_with_failures" => "degraded_ready",
209        "first_sync_ready" => "waiting_convergence",
210        "recoverable_retrying" | "sync_in_progress" => "retrying",
211        "bootstrap_only" => "waiting_first_sync",
212        "failed_before_first_sync" => "failed",
213        "empty" => "empty",
214        _ => "unknown",
215    }
216}
217
218fn static_status_reason_action(
219    readiness: &str,
220    security_count: u64,
221    first_sync_done: bool,
222    converged: bool,
223    started_total: u64,
224    finished_total: u64,
225    failed_total: u64,
226    recoverable_retry_total: u64,
227) -> (String, String) {
228    if security_count == 0 {
229        return (
230            "static security cache is empty; no stock-list rows are currently available"
231                .to_string(),
232            "check daemon startup logs, then run `futucli static-warmup` and rerun `futucli static-status`".to_string(),
233        );
234    }
235
236    if !first_sync_done {
237        let reason = match readiness {
238            "bootstrap_only" => {
239                "SQLite bootstrap rows are loaded, but backend stock-list first sync has not completed"
240            }
241            "recoverable_retrying" => {
242                "stock-list first sync is retrying after recoverable checksum rollback"
243            }
244            "sync_in_progress" => "stock-list first sync is still in progress",
245            "failed_before_first_sync" => {
246                "stock-list first sync has not completed after a backend/local sync failure"
247            }
248            _ if started_total > finished_total => "stock-list first sync is still in progress",
249            _ if failed_total > 0 => {
250                "stock-list first sync has not completed after a backend/local sync failure"
251            }
252            _ => "stock-list first sync has not completed",
253        };
254        return (
255            reason.to_string(),
256            "wait for backend stock-list first sync before C++ parity checks; rerun `futucli static-status` or trigger `futucli static-warmup` if it stays unchanged".to_string(),
257        );
258    }
259
260    if !converged {
261        return (
262            "stock-list first sync finished, but zero-delta convergence has not been observed"
263                .to_string(),
264            "basic lookup may work, but wait for zero-delta before static parity checks; rerun `futucli static-status` after the next sync".to_string(),
265        );
266    }
267
268    if failed_total > 0 || recoverable_retry_total > 0 {
269        return (
270            format!(
271                "stock-list is converged after earlier retry/failure counters: failed_total={failed_total}, recoverable_retry_total={recoverable_retry_total}"
272            ),
273            "current static cache is usable; keep the counters in bug reports if symbol mapping later looks wrong".to_string(),
274        );
275    }
276
277    (
278        "stock-list first sync and zero-delta convergence are complete".to_string(),
279        "static cache is ready for smoke and parity checks".to_string(),
280    )
281}
282
283fn derive_stock_list_readiness(
284    security_count: u64,
285    first_sync_done: bool,
286    converged: bool,
287    started_total: u64,
288    finished_total: u64,
289    failed_total: u64,
290    recoverable_retry_total: u64,
291) -> &'static str {
292    if !first_sync_done {
293        if started_total > finished_total {
294            if recoverable_retry_total > 0 {
295                return "recoverable_retrying";
296            }
297            return "sync_in_progress";
298        }
299        if security_count > 0 {
300            return "bootstrap_only";
301        }
302        if failed_total > 0 {
303            return "failed_before_first_sync";
304        }
305        return "empty";
306    }
307
308    if converged {
309        if failed_total > 0 {
310            return "converged_with_failures";
311        }
312        if recoverable_retry_total > 0 {
313            return "converged_after_recoverable_retry";
314        }
315        return "converged";
316    }
317
318    if started_total > finished_total {
319        if recoverable_retry_total > 0 {
320            return "recoverable_retrying";
321        }
322        return "sync_in_progress";
323    }
324
325    "first_sync_ready"
326}
327
328fn u64_field(value: &Value, key: &str) -> Result<u64> {
329    value
330        .get(key)
331        .and_then(Value::as_u64)
332        .ok_or_else(|| anyhow::anyhow!("cache.static_data.{key} missing or not u64"))
333}
334
335fn optional_u64_field(value: &Value, key: &str) -> u64 {
336    value.get(key).and_then(Value::as_u64).unwrap_or(0)
337}
338
339fn bool_field(value: &Value, key: &str) -> Result<bool> {
340    value
341        .get(key)
342        .and_then(Value::as_bool)
343        .ok_or_else(|| anyhow::anyhow!("cache.static_data.{key} missing or not bool"))
344}
345
346fn print_static_status(report: &StaticStatusReport, output: OutputFormat) -> Result<()> {
347    match output {
348        OutputFormat::Table | OutputFormat::Markdown => {
349            let rows = vec![
350                row("ready", report.ready),
351                row("reason", &report.reason),
352                row("action", &report.action),
353                row("security_count", report.security_count),
354                row("stock_list_readiness", &report.stock_list_readiness),
355                row(
356                    "stock_list_readiness_state",
357                    &report.stock_list_readiness_state,
358                ),
359                row(
360                    "stock_list_first_sync_done",
361                    report.stock_list_first_sync_done,
362                ),
363                row("stock_list_converged", report.stock_list_converged),
364                row(
365                    "stock_list_sync_started_total",
366                    report.stock_list_sync_started_total,
367                ),
368                row(
369                    "stock_list_sync_finished_total",
370                    report.stock_list_sync_finished_total,
371                ),
372                row(
373                    "stock_list_sync_failed_total",
374                    report.stock_list_sync_failed_total,
375                ),
376                row(
377                    "stock_list_sync_recoverable_retry_total",
378                    report.stock_list_sync_recoverable_retry_total,
379                ),
380                row(
381                    "stock_list_zero_delta_total",
382                    report.stock_list_zero_delta_total,
383                ),
384                row("stock_list_last_version", report.stock_list_last_version),
385                row(
386                    "stock_list_last_total_stocks",
387                    report.stock_list_last_total_stocks,
388                ),
389                row(
390                    "stock_list_last_cached_count",
391                    report.stock_list_last_cached_count,
392                ),
393                row(
394                    "stock_list_last_finished_ms",
395                    report
396                        .stock_list_last_finished_ms
397                        .map(|v| v.to_string())
398                        .unwrap_or_else(|| "-".to_string()),
399                ),
400            ];
401            output.print_rows(&rows, std::slice::from_ref(report))?;
402            println!();
403            println!("Findings:");
404            for finding in &report.findings {
405                println!("- [{}] {}", finding.level, finding.message);
406            }
407        }
408        OutputFormat::Json => {
409            println!("{}", serde_json::to_string_pretty(report)?);
410        }
411        OutputFormat::Jsonl => {
412            println!("{}", serde_json::to_string(report)?);
413        }
414    }
415    Ok(())
416}
417
418fn row(field: impl Into<String>, value: impl ToString) -> StaticStatusRow {
419    StaticStatusRow {
420        field: field.into(),
421        value: value.to_string(),
422    }
423}
424
425#[cfg(test)]
426#[path = "static_diag_tests.rs"]
427mod static_diag_tests;