Skip to main content

futucli/
main.rs

1//! FutuOpenD-rs 命令行客户端
2//!
3//! 连接到运行中的 `futu-opend` 网关,提供行情查询、订阅、交易等子命令,
4//! 以及交互式 REPL(`futucli repl`)。
5
6mod cli;
7mod cmd;
8mod common;
9mod output;
10mod qot_sdk_adapter;
11mod trd_sdk_adapter;
12
13use anyhow::Result;
14use clap::{Error as ClapError, Parser};
15use serde_json::json;
16use tracing_subscriber::Layer;
17use tracing_subscriber::layer::SubscriberExt;
18use tracing_subscriber::util::SubscriberInitExt;
19use tracing_subscriber::{EnvFilter, fmt};
20
21use crate::cli::Cli;
22use crate::output::OutputFormat;
23
24#[tokio::main]
25async fn main() {
26    let cli = match Cli::try_parse() {
27        Ok(cli) => cli,
28        Err(err) => {
29            if err.exit_code() == 0 {
30                err.exit();
31            }
32            let output =
33                detect_output_format_from_args(std::env::args()).unwrap_or(OutputFormat::Table);
34            if matches!(output, OutputFormat::Json | OutputFormat::Jsonl) {
35                emit_cli_parse_error(output, &err);
36                std::process::exit(err.exit_code());
37            }
38            err.exit();
39        }
40    };
41    let output = cli.output;
42
43    if let Err(err) = run(cli).await {
44        emit_cli_error(output, &err);
45        std::process::exit(1);
46    }
47}
48
49fn emit_cli_parse_error(format: OutputFormat, err: &ClapError) {
50    let value = cli_usage_error_json(&err.to_string());
51    match format {
52        OutputFormat::Json => match serde_json::to_string_pretty(&value) {
53            Ok(s) => println!("{s}"),
54            Err(_) => eprintln!("{err}"),
55        },
56        OutputFormat::Jsonl => match serde_json::to_string(&value) {
57            Ok(s) => println!("{s}"),
58            Err(_) => eprintln!("{err}"),
59        },
60        OutputFormat::Table | OutputFormat::Markdown => eprintln!("{err}"),
61    }
62}
63
64async fn run(cli: Cli) -> Result<()> {
65    let log_level = if cli.verbose {
66        "debug"
67    } else if matches!(cli.output, OutputFormat::Json | OutputFormat::Jsonl) {
68        "error"
69    } else {
70        "warn"
71    };
72    let machine_output = matches!(cli.output, OutputFormat::Json | OutputFormat::Jsonl);
73    let env_filter = if machine_output && !cli.verbose {
74        EnvFilter::new(log_level)
75    } else {
76        EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new(log_level))
77    };
78
79    // 主 fmt layer 写 stderr(CLI 输出不能污染 stdout,stdout 给 table/JSON 用)
80    let stderr_layer = fmt::layer()
81        .with_timer(futu_core::log::LocalRfc3339Timer)
82        .with_writer(std::io::stderr);
83
84    // v1.2:可选 audit JSONL 层(target = futu_audit 的 event 单独写文件 / 目录)
85    let _audit_guard = match cli.audit_log.as_deref() {
86        Some(path) => match futu_auth::audit::open_writer(path) {
87            Ok((nb_writer, guard)) => {
88                let audit_layer = fmt::layer()
89                    .json()
90                    .with_timer(futu_core::log::LocalRfc3339Timer)
91                    .with_writer(nb_writer)
92                    .with_filter(tracing_subscriber::filter::filter_fn(|meta| {
93                        meta.target() == futu_auth::audit::TARGET
94                    }));
95                tracing_subscriber::registry()
96                    .with(env_filter)
97                    .with(stderr_layer)
98                    .with(audit_layer)
99                    .init();
100                Some(guard)
101            }
102            Err(e) => {
103                eprintln!("warning: failed to open audit log {path:?}: {e}");
104                tracing_subscriber::registry()
105                    .with(env_filter)
106                    .with(stderr_layer)
107                    .init();
108                None
109            }
110        },
111        None => {
112            tracing_subscriber::registry()
113                .with(env_filter)
114                .with(stderr_layer)
115                .init();
116            None
117        }
118    };
119
120    // REPL 走专门入口(避免 dispatch 里 async 递归)
121    if matches!(cli.command, cli::Command::Repl) {
122        return cmd::repl::run(&cli.gateway, cli.output).await;
123    }
124
125    cli::dispatch(&cli.gateway, cli.output, cli.command).await
126}
127
128fn emit_cli_error(format: OutputFormat, err: &anyhow::Error) {
129    let message = if matches!(format, OutputFormat::Json | OutputFormat::Jsonl) {
130        format_error_chain_for_machine(err)
131    } else {
132        err.to_string()
133    };
134    let kind = classify_error_kind(&message);
135    let value = cli_error_json(kind, &message);
136    match format {
137        OutputFormat::Json => match serde_json::to_string_pretty(&value) {
138            Ok(s) => println!("{s}"),
139            Err(_) => eprintln!("Error: {message}"),
140        },
141        OutputFormat::Jsonl => match serde_json::to_string(&value) {
142            Ok(s) => println!("{s}"),
143            Err(_) => eprintln!("Error: {message}"),
144        },
145        OutputFormat::Table | OutputFormat::Markdown => eprintln!("Error: {message}"),
146    }
147}
148
149fn format_error_chain_for_machine(err: &anyhow::Error) -> String {
150    let mut parts = Vec::new();
151    for cause in err.chain() {
152        let s = cause.to_string();
153        if parts.last() != Some(&s) {
154            parts.push(s);
155        }
156    }
157    parts.join(": ")
158}
159
160fn cli_error_json(kind: &str, message: &str) -> serde_json::Value {
161    let machine_error_field = futu_surface_spec::ErrorContract::STANDARD.machine_error_field;
162    let mut value = json!({ "ok": false });
163    if let Some(obj) = value.as_object_mut() {
164        obj.insert(
165            machine_error_field.to_string(),
166            json!({
167                "kind": kind,
168                "message": message,
169            }),
170        );
171    }
172    value
173}
174
175fn cli_usage_error_json(message: &str) -> serde_json::Value {
176    cli_error_json("cli_usage_error", message)
177}
178
179fn detect_output_format_from_args<I, S>(args: I) -> Option<OutputFormat>
180where
181    I: IntoIterator<Item = S>,
182    S: AsRef<str>,
183{
184    let mut iter = args.into_iter().map(|s| s.as_ref().to_string()).peekable();
185    while let Some(arg) = iter.next() {
186        if arg == "-o" || arg == "--output" {
187            if let Some(next) = iter.next() {
188                return parse_output_format_token(&next);
189            }
190            return None;
191        }
192        if let Some(value) = arg.strip_prefix("--output=") {
193            return parse_output_format_token(value);
194        }
195        if let Some(value) = arg.strip_prefix("-o=") {
196            return parse_output_format_token(value);
197        }
198        if let Some(value) = arg.strip_prefix("-o")
199            && !value.is_empty()
200        {
201            return parse_output_format_token(value);
202        }
203    }
204    None
205}
206
207fn parse_output_format_token(value: &str) -> Option<OutputFormat> {
208    match value.to_ascii_lowercase().as_str() {
209        "json" => Some(OutputFormat::Json),
210        "jsonl" => Some(OutputFormat::Jsonl),
211        "table" => Some(OutputFormat::Table),
212        "markdown" | "md" => Some(OutputFormat::Markdown),
213        _ => None,
214    }
215}
216
217fn classify_error_kind(message: &str) -> &'static str {
218    if message.contains("connect to futu gateway") {
219        futu_surface_spec::ErrorContract::STANDARD.gateway_unreachable_kind
220    } else {
221        "cli_error"
222    }
223}
224
225#[cfg(test)]
226mod tests;