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