1use std::collections::hash_map::DefaultHasher;
8use std::hash::{Hash, Hasher};
9use std::sync::atomic::{AtomicU32, Ordering};
10
11use anyhow::{Result, anyhow, bail};
12use futu_core::{diagnostic_text::proto_json_numeric_qot_market_hint, trade_market};
13use prost::Message;
14use serde::Serialize;
15use serde::de::DeserializeOwned;
16
17use crate::common::connect_gateway;
18use crate::output::OutputFormat;
19
20static PACKET_SERIAL: AtomicU32 = AtomicU32::new(1);
21
22fn parse_c2s<T>(label: &str, json: &str) -> Result<T>
23where
24 T: DeserializeOwned,
25{
26 let mut value: serde_json::Value =
27 serde_json::from_str(json).map_err(|err| anyhow!("{label} c2s json: {err}"))?;
28 validate_proto_json_contract(label, &mut value)?;
29 deserialize_c2s_value(label, value)
30}
31
32fn validate_proto_json_contract(label: &str, value: &mut serde_json::Value) -> Result<()> {
33 let Some(spec) = futu_surface_spec::lookup_endpoint_by_cli_subcommand(label) else {
34 return Ok(());
35 };
36 futu_surface_spec::validate_and_normalize(spec, value)
37 .map_err(|err| anyhow!("{label} c2s json: {err}"))
38}
39
40fn parse_combo_max_c2s_json(json: &str) -> Result<futu_proto::trd_get_combo_max_trd_qtys::C2s> {
41 let c2s: futu_proto::trd_get_combo_max_trd_qtys::C2s =
42 parse_combo_c2s_json("combo-max-trd-qtys", json)?;
43 ensure_write_trd_market("combo-max-trd-qtys", c2s.header.trd_market)?;
44 Ok(c2s)
45}
46
47fn parse_place_combo_c2s_json(json: &str) -> Result<futu_proto::trd_place_combo_order::C2s> {
48 let c2s: futu_proto::trd_place_combo_order::C2s = parse_combo_c2s_json("combo-order", json)?;
49 ensure_write_trd_market("combo-order", c2s.header.trd_market)?;
50 Ok(c2s)
51}
52
53fn parse_combo_c2s_json<T>(label: &str, json: &str) -> Result<T>
54where
55 T: DeserializeOwned,
56{
57 parse_c2s_with_required_paths(
58 label,
59 json,
60 &[
61 (["header", "trd_env"].as_slice(), "header.trd_env"),
62 (["header", "acc_id"].as_slice(), "header.acc_id"),
63 (["header", "trd_market"].as_slice(), "header.trd_market"),
64 (["combo_legs"].as_slice(), "combo_legs"),
65 (["qty"].as_slice(), "qty"),
66 (["order_type"].as_slice(), "order_type"),
67 ],
68 Some(validate_combo_trade_contract),
69 )
70}
71
72fn parse_c2s_with_required_paths<T>(
73 label: &str,
74 json: &str,
75 required_paths: &[(&[&str], &'static str)],
76 extra_validator: Option<fn(&str, &serde_json::Value) -> Result<()>>,
77) -> Result<T>
78where
79 T: DeserializeOwned,
80{
81 let value: serde_json::Value =
82 serde_json::from_str(json).map_err(|err| anyhow!("{label} c2s json: {err}"))?;
83 for (path, name) in required_paths {
84 if json_path(&value, path).is_none() {
85 bail!("{label} c2s json missing required field {name}");
86 }
87 }
88 if let Some(validate) = extra_validator {
89 validate(label, &value)?;
90 }
91 deserialize_c2s_value(label, value)
92}
93
94fn deserialize_c2s_value<T>(label: &str, value: serde_json::Value) -> Result<T>
95where
96 T: DeserializeOwned,
97{
98 serde_json::from_value(value.clone()).map_err(|err| {
99 anyhow!(
100 "{label} c2s json: {err}{}",
101 proto_json_deserialize_hint(&value)
102 )
103 })
104}
105
106fn proto_json_deserialize_hint(value: &serde_json::Value) -> String {
107 if has_string_market_field(value) {
108 format!(
109 "; hint: {}",
110 proto_json_numeric_qot_market_hint().text_with_code()
111 )
112 } else {
113 String::new()
114 }
115}
116
117fn has_string_market_field(value: &serde_json::Value) -> bool {
118 match value {
119 serde_json::Value::Object(map) => map.iter().any(|(key, nested)| {
120 (key == "market" && nested.is_string()) || has_string_market_field(nested)
121 }),
122 serde_json::Value::Array(items) => items.iter().any(has_string_market_field),
123 _ => false,
124 }
125}
126
127fn json_path<'a>(value: &'a serde_json::Value, path: &[&str]) -> Option<&'a serde_json::Value> {
128 let mut current = value;
129 for segment in path {
130 current = current.get(*segment)?;
131 }
132 Some(current)
133}
134
135fn validate_combo_trade_contract(label: &str, value: &serde_json::Value) -> Result<()> {
136 match json_path(value, &["header", "acc_id"]).and_then(serde_json::Value::as_u64) {
137 Some(acc_id) if acc_id > 0 => {}
138 _ => bail!("{label} c2s json header.acc_id must be a positive integer"),
139 }
140
141 let legs = json_path(value, &["combo_legs"])
142 .and_then(serde_json::Value::as_array)
143 .ok_or_else(|| anyhow!("{label} c2s json combo_legs must be an array"))?;
144 if legs.len() < 2 {
145 bail!("{label} c2s json combo_legs must contain at least two legs");
146 }
147
148 match json_path(value, &["qty"]).and_then(serde_json::Value::as_f64) {
149 Some(qty) if qty > 0.0 => {}
150 _ => bail!("{label} c2s json qty must be positive"),
151 }
152
153 let order_type = json_path(value, &["order_type"])
154 .and_then(serde_json::Value::as_i64)
155 .ok_or_else(|| anyhow!("{label} c2s json order_type must be an integer"))?;
156 if i32::try_from(order_type).is_err() {
157 bail!("{label} c2s json order_type={order_type} is out of range");
158 }
159
160 Ok(())
161}
162
163fn print_proto_response<T>(format: OutputFormat, response: &T) -> Result<()>
164where
165 T: Serialize,
166{
167 match format {
168 OutputFormat::Table | OutputFormat::Json | OutputFormat::Markdown => {
169 println!("{}", serde_json::to_string_pretty(response)?);
170 }
171 OutputFormat::Jsonl => {
172 println!("{}", serde_json::to_string(response)?);
173 }
174 }
175 Ok(())
176}
177
178fn ensure_success(
179 label: &str,
180 ret_type: i32,
181 ret_msg: Option<&str>,
182 err_code: Option<i32>,
183) -> Result<()> {
184 if ret_type == 0 {
185 return Ok(());
186 }
187 bail!("{label} ret_type={ret_type} msg={ret_msg:?} err_code={err_code:?}")
188}
189
190async fn send_proto<Req, Resp>(
191 gateway: &str,
192 client_id: &str,
193 proto_id: u32,
194 request: Req,
195) -> Result<Resp>
196where
197 Req: Message,
198 Resp: Message + Default,
199{
200 let (client, _rx) = connect_gateway(gateway, client_id).await?;
201 let frame = client.request(proto_id, request.encode_to_vec()).await?;
202 Resp::decode(frame.body.as_ref()).map_err(|err| anyhow!("decode response: {err}"))
203}
204
205pub async fn run_verification(
206 gateway: &str,
207 verification_type: i32,
208 op: i32,
209 code: Option<String>,
210 output: OutputFormat,
211) -> Result<()> {
212 validate_verification_args(verification_type, op, code.as_deref())?;
213 let request = futu_proto::verification::Request {
214 c2s: futu_proto::verification::C2s {
215 r#type: verification_type,
216 op,
217 code,
218 },
219 };
220 let response: futu_proto::verification::Response = send_proto(
221 gateway,
222 futu_core::INTERNAL_UI_CLIENT_ID,
223 futu_core::proto_id::VERIFICATION,
224 request,
225 )
226 .await?;
227 ensure_success(
228 "verification",
229 response.ret_type,
230 response.ret_msg.as_deref(),
231 response.err_code,
232 )?;
233 print_proto_response(output, &response)
234}
235
236fn validate_verification_args(verification_type: i32, op: i32, code: Option<&str>) -> Result<()> {
237 if !matches!(verification_type, 1 | 2) {
238 bail!("verification --type must be 1 (Picture) or 2 (Phone)");
239 }
240 if !matches!(op, 1 | 2) {
241 bail!("verification --op must be 1 (Request) or 2 (InputAndLogin)");
242 }
243 if op == 2 && code.is_none() {
244 bail!("verification --code is required for --op 2");
245 }
246 Ok(())
247}
248
249macro_rules! qot_proto_json_command {
250 ($fn_name:ident, $label:literal, $proto_id:expr, $module:ident) => {
251 pub async fn $fn_name(gateway: &str, c2s_json: &str, output: OutputFormat) -> Result<()> {
252 let c2s = parse_c2s::<futu_proto::$module::C2s>($label, c2s_json)?;
253 let request = futu_proto::$module::Request { c2s };
254 let response: futu_proto::$module::Response =
255 send_proto(gateway, concat!("futucli-", $label), $proto_id, request).await?;
256 ensure_success(
257 $label,
258 response.ret_type,
259 response.ret_msg.as_deref(),
260 response.err_code,
261 )?;
262 print_proto_response(output, &response)
263 }
264 };
265}
266
267qot_proto_json_command!(
268 run_option_quote,
269 "option-quote",
270 futu_core::proto_id::QOT_GET_OPTION_QUOTE,
271 qot_get_option_quote
272);
273qot_proto_json_command!(
274 run_option_strategy,
275 "option-strategy",
276 futu_core::proto_id::QOT_GET_OPTION_STRATEGY,
277 qot_get_option_strategy
278);
279qot_proto_json_command!(
280 run_option_strategy_analysis,
281 "option-strategy-analysis",
282 futu_core::proto_id::QOT_GET_OPTION_STRATEGY_ANALYSIS,
283 qot_get_option_strategy_analysis
284);
285qot_proto_json_command!(
286 run_option_strategy_spread,
287 "option-strategy-spread",
288 futu_core::proto_id::QOT_GET_OPTION_STRATEGY_SPREAD,
289 qot_get_option_strategy_spread
290);
291qot_proto_json_command!(
292 run_earnings_calendar,
293 "earnings-calendar",
294 futu_core::proto_id::QOT_GET_EARNINGS_CALENDAR,
295 qot_get_earnings_calendar
296);
297qot_proto_json_command!(
298 run_macro_indicator_list,
299 "macro-indicator-list",
300 futu_core::proto_id::QOT_GET_MACRO_INDICATOR_LIST,
301 qot_get_macro_indicator_list
302);
303qot_proto_json_command!(
304 run_macro_indicator_history,
305 "macro-indicator-history",
306 futu_core::proto_id::QOT_GET_MACRO_INDICATOR_HISTORY,
307 qot_get_macro_indicator_history
308);
309qot_proto_json_command!(
310 run_fed_watch_target_rate,
311 "fed-watch-target-rate",
312 futu_core::proto_id::QOT_GET_FED_WATCH_TARGET_RATE,
313 qot_get_fed_watch_target_rate
314);
315qot_proto_json_command!(
316 run_fed_watch_dot_plot,
317 "fed-watch-dot-plot",
318 futu_core::proto_id::QOT_GET_FED_WATCH_DOT_PLOT,
319 qot_get_fed_watch_dot_plot
320);
321qot_proto_json_command!(
322 run_earnings_beat_rank,
323 "earnings-beat-rank",
324 futu_core::proto_id::QOT_GET_EARNINGS_BEAT_RANK,
325 qot_get_earnings_beat_rank
326);
327qot_proto_json_command!(
328 run_dividend_rank,
329 "dividend-rank",
330 futu_core::proto_id::QOT_GET_DIVIDEND_RANK,
331 qot_get_dividend_rank
332);
333qot_proto_json_command!(
334 run_dividend_calendar,
335 "dividend-calendar",
336 futu_core::proto_id::QOT_GET_DIVIDEND_CALENDAR,
337 qot_get_dividend_calendar
338);
339qot_proto_json_command!(
340 run_economic_calendar,
341 "economic-calendar",
342 futu_core::proto_id::QOT_GET_ECONOMIC_CALENDAR,
343 qot_get_economic_calendar
344);
345qot_proto_json_command!(
346 run_us_pre_market_rank,
347 "us-pre-market-rank",
348 futu_core::proto_id::QOT_GET_US_PRE_MARKET_RANK,
349 qot_get_us_pre_market_rank
350);
351qot_proto_json_command!(
352 run_us_after_hours_rank,
353 "us-after-hours-rank",
354 futu_core::proto_id::QOT_GET_US_AFTER_HOURS_RANK,
355 qot_get_us_after_hours_rank
356);
357qot_proto_json_command!(
358 run_us_overnight_rank,
359 "us-overnight-rank",
360 futu_core::proto_id::QOT_GET_US_OVERNIGHT_RANK,
361 qot_get_us_overnight_rank
362);
363qot_proto_json_command!(
364 run_top_movers_rank,
365 "top-movers-rank",
366 futu_core::proto_id::QOT_GET_TOP_MOVERS_RANK,
367 qot_get_top_movers_rank
368);
369qot_proto_json_command!(
370 run_hot_list,
371 "hot-list",
372 futu_core::proto_id::QOT_GET_HOT_LIST,
373 qot_get_hot_list
374);
375qot_proto_json_command!(
376 run_short_selling_rank,
377 "short-selling-rank",
378 futu_core::proto_id::QOT_GET_SHORT_SELLING_RANK,
379 qot_get_short_selling_rank
380);
381qot_proto_json_command!(
382 run_period_change_rank,
383 "period-change-rank",
384 futu_core::proto_id::QOT_GET_PERIOD_CHANGE_RANK,
385 qot_get_period_change_rank
386);
387qot_proto_json_command!(
388 run_high_dividend_soe_rank,
389 "high-dividend-soe-rank",
390 futu_core::proto_id::QOT_GET_HIGH_DIVIDEND_SOE_RANK,
391 qot_get_high_dividend_soe_rank
392);
393qot_proto_json_command!(
394 run_institution_list,
395 "institution-list",
396 futu_core::proto_id::QOT_GET_INSTITUTION_LIST,
397 qot_get_institution_list
398);
399qot_proto_json_command!(
400 run_institution_profile,
401 "institution-profile",
402 futu_core::proto_id::QOT_GET_INSTITUTION_PROFILE,
403 qot_get_institution_profile
404);
405qot_proto_json_command!(
406 run_institution_distribution,
407 "institution-distribution",
408 futu_core::proto_id::QOT_GET_INSTITUTION_DISTRIBUTION,
409 qot_get_institution_distribution
410);
411qot_proto_json_command!(
412 run_institution_holding_change,
413 "institution-holding-change",
414 futu_core::proto_id::QOT_GET_INSTITUTION_HOLDING_CHANGE,
415 qot_get_institution_holding_change
416);
417qot_proto_json_command!(
418 run_institution_holding_list,
419 "institution-holding-list",
420 futu_core::proto_id::QOT_GET_INSTITUTION_HOLDING_LIST,
421 qot_get_institution_holding_list
422);
423qot_proto_json_command!(
424 run_ark_fund_holding,
425 "ark-fund-holding",
426 futu_core::proto_id::QOT_GET_ARK_FUND_HOLDING,
427 qot_get_ark_fund_holding
428);
429qot_proto_json_command!(
430 run_ark_stock_dynamic,
431 "ark-stock-dynamic",
432 futu_core::proto_id::QOT_GET_ARK_STOCK_DYNAMIC,
433 qot_get_ark_stock_dynamic
434);
435qot_proto_json_command!(
436 run_ark_active_transaction,
437 "ark-active-transaction",
438 futu_core::proto_id::QOT_GET_ARK_ACTIVE_TRANSACTION,
439 qot_get_ark_active_transaction
440);
441qot_proto_json_command!(
442 run_rating_change,
443 "rating-change",
444 futu_core::proto_id::QOT_GET_RATING_CHANGE,
445 qot_get_rating_change
446);
447qot_proto_json_command!(
448 run_search_quote,
449 "search-quote",
450 futu_core::proto_id::QOT_GET_SEARCH_QUOTE,
451 qot_get_search_quote
452);
453qot_proto_json_command!(
454 run_search_news,
455 "search-news",
456 futu_core::proto_id::QOT_GET_SEARCH_NEWS,
457 qot_get_search_news
458);
459qot_proto_json_command!(
460 run_indicator_list,
461 "indicator-list",
462 futu_core::proto_id::QOT_GET_INDICATOR_LIST,
463 qot_get_indicator_list
464);
465qot_proto_json_command!(
466 run_option_market_statistic,
467 "option-market-statistic",
468 futu_core::proto_id::QOT_GET_OPTION_MARKET_STATISTIC,
469 qot_get_option_market_statistic
470);
471qot_proto_json_command!(
472 run_option_underlying_his_statistic,
473 "option-underlying-his-statistic",
474 futu_core::proto_id::QOT_GET_OPTION_UNDERLYING_HIS_STATISTIC,
475 qot_get_option_underlying_his_statistic
476);
477qot_proto_json_command!(
478 run_option_underlying_overview,
479 "option-underlying-overview",
480 futu_core::proto_id::QOT_GET_OPTION_UNDERLYING_OVERVIEW,
481 qot_get_option_underlying_overview
482);
483qot_proto_json_command!(
484 run_option_underlying_his_volatility,
485 "option-underlying-his-volatility",
486 futu_core::proto_id::QOT_GET_OPTION_UNDERLYING_HIS_VOLATILITY,
487 qot_get_option_underlying_his_volatility
488);
489qot_proto_json_command!(
490 run_option_underlying_rank,
491 "option-underlying-rank",
492 futu_core::proto_id::QOT_GET_OPTION_UNDERLYING_RANK,
493 qot_get_option_underlying_rank
494);
495qot_proto_json_command!(
496 run_option_rank,
497 "option-rank",
498 futu_core::proto_id::QOT_GET_OPTION_RANK,
499 qot_get_option_rank
500);
501qot_proto_json_command!(
502 run_option_event,
503 "option-event",
504 futu_core::proto_id::QOT_GET_OPTION_EVENT,
505 qot_get_option_event
506);
507qot_proto_json_command!(
508 run_option_event_alert,
509 "option-event-alert",
510 futu_core::proto_id::QOT_GET_OPTION_EVENT_ALERT,
511 qot_get_option_event_alert
512);
513qot_proto_json_command!(
514 run_set_option_event_alert,
515 "set-option-event-alert",
516 futu_core::proto_id::QOT_SET_OPTION_EVENT_ALERT,
517 qot_set_option_event_alert
518);
519qot_proto_json_command!(
520 run_option_zero_dte_screener,
521 "option-zero-dte-screener",
522 futu_core::proto_id::QOT_GET_OPTION_ZERO_DTE_SCREENER,
523 qot_get_option_zero_dte_screener
524);
525qot_proto_json_command!(
526 run_option_zero_dte_contract,
527 "option-zero-dte-contract",
528 futu_core::proto_id::QOT_GET_OPTION_ZERO_DTE_CONTRACT,
529 qot_get_option_zero_dte_contract
530);
531qot_proto_json_command!(
532 run_option_earnings_screener,
533 "option-earnings-screener",
534 futu_core::proto_id::QOT_GET_OPTION_EARNINGS_SCREENER,
535 qot_get_option_earnings_screener
536);
537qot_proto_json_command!(
538 run_option_seller_screener,
539 "option-seller-screener",
540 futu_core::proto_id::QOT_GET_OPTION_SELLER_SCREENER,
541 qot_get_option_seller_screener
542);
543qot_proto_json_command!(
544 run_industrial_chain_list,
545 "industrial-chain-list",
546 futu_core::proto_id::QOT_GET_INDUSTRIAL_CHAIN_LIST,
547 qot_get_industrial_chain_list
548);
549qot_proto_json_command!(
550 run_industrial_chain_detail,
551 "industrial-chain-detail",
552 futu_core::proto_id::QOT_GET_INDUSTRIAL_CHAIN_DETAIL,
553 qot_get_industrial_chain_detail
554);
555qot_proto_json_command!(
556 run_industrial_chain_by_plate,
557 "industrial-chain-by-plate",
558 futu_core::proto_id::QOT_GET_INDUSTRIAL_CHAIN_BY_PLATE,
559 qot_get_industrial_chain_by_plate
560);
561qot_proto_json_command!(
562 run_industrial_plate_info,
563 "industrial-plate-info",
564 futu_core::proto_id::QOT_GET_INDUSTRIAL_PLATE_INFO,
565 qot_get_industrial_plate_info
566);
567qot_proto_json_command!(
568 run_industrial_plate_stock,
569 "industrial-plate-stock",
570 futu_core::proto_id::QOT_GET_INDUSTRIAL_PLATE_STOCK,
571 qot_get_industrial_plate_stock
572);
573qot_proto_json_command!(
574 run_heat_map_data,
575 "heat-map-data",
576 futu_core::proto_id::QOT_GET_HEAT_MAP_DATA,
577 qot_get_heat_map_data
578);
579qot_proto_json_command!(
580 run_rise_fall_distribution,
581 "rise-fall-distribution",
582 futu_core::proto_id::QOT_GET_RISE_FALL_DISTRIBUTION,
583 qot_get_rise_fall_distribution
584);
585
586pub async fn run_combo_max_trd_qtys(
587 gateway: &str,
588 c2s_json: &str,
589 output: OutputFormat,
590) -> Result<()> {
591 let c2s = parse_combo_max_c2s_json(c2s_json)?;
592 let request = futu_proto::trd_get_combo_max_trd_qtys::Request { c2s };
593 let response: futu_proto::trd_get_combo_max_trd_qtys::Response = send_proto(
594 gateway,
595 "futucli-combo-max-trd-qtys",
596 futu_core::proto_id::TRD_GET_COMBO_MAX_TRD_QTYS,
597 request,
598 )
599 .await?;
600 ensure_success(
601 "combo-max-trd-qtys",
602 response.ret_type,
603 response.ret_msg.as_deref(),
604 response.err_code,
605 )?;
606 print_proto_response(output, &response)
607}
608
609pub async fn run_place_combo_order(
610 gateway: &str,
611 c2s_json: &str,
612 confirm: bool,
613 idempotency_key: Option<String>,
614 output: OutputFormat,
615) -> Result<()> {
616 let mut c2s = parse_place_combo_c2s_json(c2s_json)?;
617
618 ensure_combo_order_confirmed(c2s.header.trd_env, confirm)?;
619
620 let (client, _rx) = connect_gateway(gateway, "futucli-combo-order").await?;
621 c2s.packet_id = match idempotency_key.as_deref() {
622 Some(key) => packet_id_for_idempotency_key(key),
623 None => {
624 let conn_id = client
625 .conn_id()
626 .ok_or_else(|| anyhow!("combo-order missing InitConnect conn_id"))?;
627 next_packet_id(conn_id)
628 }
629 };
630
631 let request = futu_proto::trd_place_combo_order::Request { c2s };
632 let frame = client
633 .request(
634 futu_core::proto_id::TRD_PLACE_COMBO_ORDER,
635 request.encode_to_vec(),
636 )
637 .await?;
638 let response = futu_proto::trd_place_combo_order::Response::decode(frame.body.as_ref())
639 .map_err(|err| anyhow!("decode combo-order response: {err}"))?;
640 ensure_success(
641 "combo-order",
642 response.ret_type,
643 response.ret_msg.as_deref(),
644 response.err_code,
645 )?;
646 print_proto_response(output, &response)
647}
648
649fn next_packet_id(conn_id: u64) -> futu_proto::common::PacketId {
650 let serial_no = PACKET_SERIAL.fetch_add(1, Ordering::Relaxed);
651 futu_proto::common::PacketId { conn_id, serial_no }
652}
653
654fn ensure_combo_order_confirmed(trd_env: i32, confirm: bool) -> Result<()> {
655 if trd_env == 1 && !confirm {
656 bail!("combo-order real env requires --confirm");
657 }
658 Ok(())
659}
660
661fn ensure_write_trd_market(label: &str, trd_market: i32) -> Result<()> {
662 if let Some(fund_label) = trade_market::canonical_fund_trd_market_label(trd_market) {
663 bail!(
664 "{label} header.trd_market={trd_market} ({fund_label}) is view-only; \
665 use a write-capable main market for combo trade paths"
666 );
667 }
668 if trade_market::trd_market_label(trd_market).is_none() {
669 bail!("{label} unsupported header.trd_market={trd_market}");
670 }
671 Ok(())
672}
673
674fn packet_id_for_idempotency_key(key: &str) -> futu_proto::common::PacketId {
678 let mut hasher = DefaultHasher::new();
679 key.hash(&mut hasher);
680 futu_proto::common::PacketId {
681 conn_id: hasher.finish(),
682 serial_no: 0,
683 }
684}
685
686#[cfg(test)]
687mod tests;