1use std::io::{self, Write};
4
5mod args;
6mod commands;
7mod dispatch;
8mod groups;
9mod help;
10mod output;
11mod schema;
12
13#[cfg(test)]
14mod tests;
15
16pub use args::Cli;
17
18use clap::CommandFactory;
19use reqwest::Url;
20use serde_json::{Value, json};
21
22use commands::execute;
23use output::render_output;
24
25use crate::client::FmpClient;
26use crate::error::{Error, Result};
27
28fn print_stdout_line(output: &str) {
31 if let Err(e) = writeln!(io::stdout(), "{output}")
32 && e.kind() != io::ErrorKind::BrokenPipe
33 {
34 panic!("failed printing to stdout: {e}");
35 }
36}
37
38pub async fn run(cli: Cli) -> Result<()> {
44 if let args::Command::Help { command } = &cli.command {
46 if let Some(topic) = command {
47 print_stdout_line(help_topic_text(topic));
48 } else {
49 print_group_help("help")?;
50 }
51 return Ok(());
52 }
53
54 if let args::Command::Schema = &cli.command {
56 let data = schema::schema_payload();
57 let output = serde_json::to_string(&data).map_err(crate::error::Error::Json)?;
58 print_stdout_line(&output);
59 return Ok(());
60 }
61
62 if let args::Command::Commands { grouped } = &cli.command {
64 let cmd = <Cli as CommandFactory>::command();
65 let output = if *grouped {
66 grouped_commands(&cmd)
67 } else {
68 leaf_commands(&cmd)
69 };
70 for line in output {
71 print_stdout_line(&line);
72 }
73 return Ok(());
74 }
75
76 if let args::Command::Completions { shell } = &cli.command {
78 let mut cmd = <Cli as CommandFactory>::command();
79 let mut buf = Vec::new();
80 clap_complete::generate(shell.to_owned(), &mut cmd, "fmp-agent", &mut buf);
81 let output = String::from_utf8(buf).expect("completions output is valid UTF-8");
82 print_stdout_line(&output);
83 return Ok(());
84 }
85
86 if let args::Command::Doctor = &cli.command {
88 let output = serde_json::to_string(&doctor_report(&cli.base_url, cli.api_key.as_deref()))
89 .map_err(crate::error::Error::Json)?;
90 print_stdout_line(&output);
91 return Ok(());
92 }
93
94 if let Some(group_name) = bare_group_name(&cli.command) {
95 print_group_help(group_name)?;
96 return Ok(());
97 }
98
99 let Some(api_key) = cli.api_key.filter(|key| !key.trim().is_empty()) else {
100 return Err(Error::MissingApiKey);
101 };
102
103 let client = FmpClient::with_base_url(api_key, &cli.base_url)?;
104 let payload = execute(&client, &cli.command).await?;
105 if cli.strict_empty {
106 payload.reject_strict_empty()?;
107 }
108 if let Some(output) = render_output(payload)? {
109 print_stdout_line(&output);
110 }
111
112 Ok(())
113}
114
115fn doctor_report(base_url: &str, api_key: Option<&str>) -> Value {
116 let api_key_configured = api_key.is_some_and(|key| !key.trim().is_empty());
117 let base_url_display = display_base_url(base_url);
118 let base_url_error = FmpClient::with_base_url("", base_url)
119 .err()
120 .map(|error| json!({ "kind": error.kind(), "message": error.to_string() }));
121 let base_url_valid = base_url_error.is_none();
122 let ok = api_key_configured && base_url_valid;
123
124 json!({
125 "ok": ok,
126 "version": env!("CARGO_PKG_VERSION"),
127 "base_url": {
128 "value": base_url_display,
129 "valid": base_url_valid,
130 "error": base_url_error,
131 },
132 "api_key": {
133 "configured": api_key_configured,
134 "status": if api_key_configured { "ok" } else { "missing" },
135 },
136 "live_connectivity": {
137 "checked": false,
138 "status": "skipped",
139 "reason": "doctor performs local checks only and does not consume FMP API quota",
140 },
141 })
142}
143
144fn display_base_url(base_url: &str) -> String {
145 let Ok(mut url) = Url::parse(base_url) else {
146 return "<invalid URL>".to_owned();
147 };
148
149 let _ = url.set_username("");
150 let _ = url.set_password(None);
151 url.set_query(None);
152 url.set_fragment(None);
153 url.to_string()
154}
155
156fn bare_group_name(command: &args::Command) -> Option<&'static str> {
157 match command {
158 args::Command::Company { command: None } => Some("company"),
159 args::Command::Market { command: None } => Some("market"),
160 args::Command::Fundamentals { command: None } => Some("fundamentals"),
161 args::Command::Analyst { command: None } => Some("analyst"),
162 args::Command::Insider { command: None } => Some("insider"),
163 args::Command::Calendar { command: None } => Some("calendar"),
164 args::Command::MacroEcon { command: None } => Some("macro"),
165 args::Command::Technical { command: None } => Some("technical"),
166 args::Command::Sec { command: None } => Some("sec"),
167 args::Command::Etf { command: None } => Some("etf"),
168 args::Command::Crypto { command: None } => Some("crypto"),
169 args::Command::Forex { command: None } => Some("forex"),
170 args::Command::News { command: None } => Some("news"),
171 _ => None,
172 }
173}
174
175fn help_topic_text(topic: &args::HelpTopic) -> &'static str {
176 match topic {
177 args::HelpTopic::Environment => help::HELP_ENVIRONMENT_LONG,
178 args::HelpTopic::ExitCodes => help::HELP_EXIT_CODES_LONG,
179 args::HelpTopic::Schema => help::HELP_SCHEMA_LONG,
180 args::HelpTopic::Troubleshooting => help::HELP_TROUBLESHOOTING_LONG,
181 args::HelpTopic::Examples => help::HELP_EXAMPLES_LONG,
182 }
183}
184
185fn leaf_commands(cmd: &clap::Command) -> Vec<String> {
188 let mut leaves = Vec::new();
189 for sub in cmd.get_subcommands() {
190 let real_children: Vec<_> = sub
191 .get_subcommands()
192 .filter(|c| c.get_name() != "help")
193 .collect();
194
195 if sub.get_name() == "help" && real_children.is_empty() {
196 continue;
197 }
198
199 if real_children.is_empty() {
200 leaves.push(sub.get_name().to_owned());
201 } else {
202 for child in leaf_commands(sub) {
203 leaves.push(format!("{} {child}", sub.get_name()));
204 }
205 }
206 }
207 leaves.sort();
208 leaves
209}
210
211fn grouped_commands(cmd: &clap::Command) -> Vec<String> {
214 let mut lines = Vec::new();
215
216 for sub in cmd.get_subcommands() {
220 let children: Vec<_> = sub
221 .get_subcommands()
222 .filter(|c| c.get_name() != "help")
223 .collect();
224
225 if sub.get_name() == "help" && children.is_empty() {
226 continue;
227 }
228
229 if children.is_empty() {
230 continue;
232 }
233
234 lines.push(sub.get_name().to_owned());
236
237 let max_width = children
238 .iter()
239 .map(|c| format!("{} {}", sub.get_name(), c.get_name()).len())
240 .max()
241 .unwrap_or(0);
242
243 for child in &children {
244 let full_path = format!("{} {}", sub.get_name(), child.get_name());
245 let about = child.get_about().map(|a| a.to_string()).unwrap_or_default();
246 lines.push(format!(" {full_path:<max_width$} {about}"));
247 }
248 lines.push(String::new());
249 }
250
251 let leaves: Vec<&clap::Command> = cmd
255 .get_subcommands()
256 .filter(|s| {
257 s.get_name() != "help"
258 && s.get_subcommands()
259 .filter(|c| c.get_name() != "help")
260 .count()
261 == 0
262 })
263 .collect();
264
265 const DISCOVERY: &[&str] = &["doctor", "schema", "commands", "completions"];
266 let discovery_leaves: Vec<_> = leaves
267 .iter()
268 .filter(|l| DISCOVERY.contains(&l.get_name()))
269 .copied()
270 .collect();
271 let alias_leaves: Vec<_> = leaves
272 .into_iter()
273 .filter(|l| !DISCOVERY.contains(&l.get_name()))
274 .collect();
275
276 if !discovery_leaves.is_empty() {
278 lines.push("discovery".to_owned());
279 let max_width = discovery_leaves
280 .iter()
281 .map(|l| l.get_name().len())
282 .max()
283 .unwrap_or(0);
284 for leaf in &discovery_leaves {
285 let about = leaf.get_about().map(|a| a.to_string()).unwrap_or_default();
286 lines.push(format!(" {:<max_width$} {about}", leaf.get_name()));
287 }
288 lines.push(String::new());
289 }
290
291 if !alias_leaves.is_empty() {
293 lines.push("aliases".to_owned());
294 let max_width = alias_leaves
295 .iter()
296 .map(|l| l.get_name().len())
297 .max()
298 .unwrap_or(0);
299 for leaf in &alias_leaves {
300 let about = leaf.get_about().map(|a| a.to_string()).unwrap_or_default();
301 lines.push(format!(" {:<max_width$} {about}", leaf.get_name()));
302 }
303 lines.push(String::new());
304 }
305
306 lines
307}
308
309pub(crate) fn print_group_help(group_name: &str) -> Result<()> {
313 let mut command = <Cli as CommandFactory>::command();
314 let group = command
315 .find_subcommand_mut(group_name)
316 .expect("group name must match a registered Clap subcommand");
317
318 let help = group
322 .clone()
323 .override_usage(format!("fmp-agent {group_name} [OPTIONS] [COMMAND]"))
324 .render_help()
325 .to_string();
326
327 print_stdout_line(&help);
328
329 Ok(())
330}