Skip to main content

futucli/cmd/
version_check.rs

1use std::time::Duration;
2
3use anyhow::{Context, Result, bail};
4use futu_core::diagnostic_text::update_check_unavailable_action;
5use serde::{Deserialize, Serialize};
6use serde_json::json;
7
8use crate::output::OutputFormat;
9
10pub const DEFAULT_UPDATE_CHECK_URL: &str = "https://futuapi.com/version.json";
11const SUPPORTED_VERSION_MANIFEST_SCHEMA: u32 = 1;
12#[cfg(test)]
13const BUNDLED_VERSION_MANIFEST_JSON: &str = include_str!("../../../../docs-site/docs/version.json");
14
15#[derive(Debug, Clone, Deserialize, Serialize)]
16pub struct VersionManifest {
17    pub schema_version: u32,
18    pub latest_version: String,
19    pub minimum_supported_version: Option<String>,
20    pub recommendation: Option<String>,
21    pub release_url: Option<String>,
22    pub changelog_url: Option<String>,
23    pub message: Option<String>,
24}
25
26#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
27#[serde(rename_all = "kebab-case")]
28pub enum UpdateLevel {
29    UpToDate,
30    Info,
31    Recommended,
32    Critical,
33}
34
35#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
36pub struct UpdateDecision {
37    pub level: UpdateLevel,
38    pub current_version: String,
39    pub latest_version: String,
40    pub message: String,
41    pub action: String,
42}
43
44pub fn decide_update(current_version: &str, manifest: &VersionManifest) -> Result<UpdateDecision> {
45    if manifest.schema_version != SUPPORTED_VERSION_MANIFEST_SCHEMA {
46        bail!(
47            "unsupported version manifest schema_version={} (supported={SUPPORTED_VERSION_MANIFEST_SCHEMA}); {}",
48            manifest.schema_version,
49            update_check_unavailable_action().text_with_code()
50        );
51    }
52
53    let current = current_version.trim();
54    let latest = manifest.latest_version.trim();
55    let current_parts = parse_version_parts(current);
56    let latest_parts = parse_version_parts(latest);
57    let minimum_parts = manifest
58        .minimum_supported_version
59        .as_deref()
60        .and_then(parse_version_parts);
61    let current_below_minimum = current_parts
62        .as_ref()
63        .zip(minimum_parts.as_ref())
64        .is_some_and(|(current, minimum)| current < minimum);
65
66    let newer_available = current_parts
67        .as_ref()
68        .zip(latest_parts.as_ref())
69        .map_or_else(|| current != latest, |(current, latest)| current < latest);
70    let release_url = manifest
71        .release_url
72        .as_deref()
73        .unwrap_or("https://futuapi.com/download/");
74    let changelog_url = manifest
75        .changelog_url
76        .as_deref()
77        .unwrap_or("https://futuapi.com/changelog/");
78    let action = format!("下载: {release_url}; 变更记录: {changelog_url}");
79
80    if current_below_minimum {
81        let minimum = manifest
82            .minimum_supported_version
83            .as_deref()
84            .unwrap_or("<unknown>");
85        return Ok(UpdateDecision {
86            level: UpdateLevel::Critical,
87            current_version: current.to_string(),
88            latest_version: latest.to_string(),
89            message: format!(
90                "current version {current} is below minimum supported version {minimum}; latest is {latest}"
91            ),
92            action,
93        });
94    }
95
96    if !newer_available {
97        return Ok(UpdateDecision {
98            level: UpdateLevel::UpToDate,
99            current_version: current.to_string(),
100            latest_version: latest.to_string(),
101            message: format!("futu-opend-rs {current} is up to date"),
102            action: "无需升级".to_string(),
103        });
104    }
105
106    let level = match manifest
107        .recommendation
108        .as_deref()
109        .unwrap_or("info")
110        .trim()
111        .to_ascii_lowercase()
112        .as_str()
113    {
114        "critical" => UpdateLevel::Critical,
115        "recommended" => UpdateLevel::Recommended,
116        _ => UpdateLevel::Info,
117    };
118    let version_suffix = format!("latest={latest}, current={current}");
119    let default_message = format!("new futu-opend-rs version available: {version_suffix}");
120    let message = manifest
121        .message
122        .as_deref()
123        .map(|message| format!("{message} ({version_suffix})"))
124        .unwrap_or(default_message);
125    Ok(UpdateDecision {
126        level,
127        current_version: current.to_string(),
128        latest_version: latest.to_string(),
129        message,
130        action,
131    })
132}
133
134pub async fn run_version(
135    check: bool,
136    url: Option<&str>,
137    timeout_ms: u64,
138    output: OutputFormat,
139) -> Result<()> {
140    let current_version = env!("CARGO_PKG_VERSION");
141    if !check {
142        return print_version_report(
143            output,
144            json!({
145                "current_version": current_version,
146                "update_check": "disabled",
147                "hint": "run `futucli version --check` to check https://futuapi.com/version.json",
148            }),
149        );
150    }
151
152    let decision = check_for_update(current_version, url, timeout_ms).await?;
153    print_version_report(
154        output,
155        json!({
156            "current_version": current_version,
157            "latest_version": decision.latest_version,
158            "level": decision.level,
159            "message": decision.message,
160            "action": decision.action,
161            "update_check_url": resolve_update_check_url(url),
162        }),
163    )
164}
165
166pub async fn check_for_update(
167    current_version: &str,
168    cli_url: Option<&str>,
169    timeout_ms: u64,
170) -> Result<UpdateDecision> {
171    let url = resolve_update_check_url(cli_url);
172    let client = reqwest::Client::builder()
173        .timeout(Duration::from_millis(timeout_ms.max(1)))
174        .build()
175        .context("build update-check HTTP client")?;
176    let manifest = fetch_version_manifest(&client, &url).await?;
177    decide_update(current_version, &manifest)
178}
179
180pub fn resolve_update_check_url(cli_url: Option<&str>) -> String {
181    if let Some(url) = cli_url
182        && !url.trim().is_empty()
183    {
184        return url.trim().to_string();
185    }
186    if let Ok(url) = std::env::var("FUTU_UPDATE_CHECK_URL")
187        && !url.trim().is_empty()
188    {
189        return url.trim().to_string();
190    }
191    DEFAULT_UPDATE_CHECK_URL.to_string()
192}
193
194fn print_version_report(output: OutputFormat, value: serde_json::Value) -> Result<()> {
195    match output {
196        OutputFormat::Json => println!("{}", serde_json::to_string_pretty(&value)?),
197        OutputFormat::Jsonl => println!("{}", serde_json::to_string(&value)?),
198        OutputFormat::Table | OutputFormat::Markdown => {
199            println!("futucli version");
200            if let Some(current) = value.get("current_version").and_then(|v| v.as_str()) {
201                println!("current_version: {current}");
202            }
203            if let Some(latest) = value.get("latest_version").and_then(|v| v.as_str()) {
204                println!("latest_version: {latest}");
205            }
206            if let Some(level) = value.get("level").and_then(|v| v.as_str()) {
207                println!("level: {level}");
208            }
209            if let Some(message) = value.get("message").and_then(|v| v.as_str()) {
210                println!("message: {message}");
211            }
212            if let Some(action) = value.get("action").and_then(|v| v.as_str()) {
213                println!("next: {action}");
214            }
215            if let Some(hint) = value.get("hint").and_then(|v| v.as_str()) {
216                println!("hint: {hint}");
217            }
218        }
219    }
220    Ok(())
221}
222
223async fn fetch_version_manifest(client: &reqwest::Client, url: &str) -> Result<VersionManifest> {
224    client
225        .get(url)
226        .send()
227        .await
228        .with_context(|| format!("GET {url} failed"))?
229        .error_for_status()
230        .with_context(|| {
231            format!(
232                "GET {url} returned non-success status; {}",
233                update_check_unavailable_action().text_with_code()
234            )
235        })?
236        .json::<VersionManifest>()
237        .await
238        .with_context(|| {
239            format!(
240                "parse {url} as version manifest; {}",
241                update_check_unavailable_action().text_with_code()
242            )
243        })
244}
245
246#[cfg(test)]
247pub(crate) fn bundled_version_manifest() -> Result<VersionManifest> {
248    serde_json::from_str(BUNDLED_VERSION_MANIFEST_JSON)
249        .context("parse bundled docs-site/docs/version.json as version manifest")
250}
251
252fn parse_version_parts(version: &str) -> Option<Vec<u64>> {
253    let normalized = version.trim().trim_start_matches('v');
254    let mut parts = Vec::new();
255    for part in normalized.split('.') {
256        if part.is_empty() || !part.chars().all(|ch| ch.is_ascii_digit()) {
257            return None;
258        }
259        parts.push(part.parse().ok()?);
260    }
261    if parts.is_empty() { None } else { Some(parts) }
262}