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}