1mod 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 let stderr_layer = fmt::layer()
81 .with_timer(futu_core::log::LocalRfc3339Timer)
82 .with_writer(std::io::stderr);
83
84 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 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;