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