Skip to main content

rusty_fmp/cli/
mod.rs

1//! Command line parsing and output rendering.
2
3use 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
28/// Writes `output` to stdout followed by a newline, suppressing broken pipe
29/// errors so the CLI exits cleanly when a downstream consumer closes the pipe.
30fn 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
38/// Runs a parsed CLI invocation.
39///
40/// # Errors
41///
42/// Returns an error when configuration is missing, an API call fails, or JSON output cannot be rendered.
43pub async fn run(cli: Cli) -> Result<()> {
44    // Help topics are metadata-only and do not require an API key.
45    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    // Schema is metadata-only and does not require an API key.
55    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    // Commands listing is metadata-only and does not require an API key.
63    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    // Shell completions are metadata-only and do not require an API key.
77    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    // Doctor is local-only and does not require an API key or network access.
87    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
185/// Collect leaf command paths from a clap command tree, one `group subcommand`
186/// string per leaf, sorted alphabetically.
187fn 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
211/// Collect grouped command paths from a clap command tree, organized by group
212/// with about text, suitable for human reading.
213fn grouped_commands(cmd: &clap::Command) -> Vec<String> {
214    let mut lines = Vec::new();
215
216    // First pass: collect groups and their children.  The help subcommand
217    // (injected by Clap) is a leaf and falls through the children.is_empty()
218    // check below without a dedicated name guard.
219    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            // Top-level leaf (alias or metadata) -- handled in second pass.
231            continue;
232        }
233
234        // Group with children.
235        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    // Second pass: collect top-level standalone commands, split into discovery
252    // commands (doctor, schema, commands, completions) and convenience aliases
253    // (quote, historical, profile, earnings).
254    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    // Discovery commands section.
277    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    // Alias commands section.
292    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
309/// Prints the help text for a named group subcommand.
310///
311/// Called when the user invokes `fmp-agent <group>` without a subcommand.
312pub(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    // Override usage to include the full binary-qualified path and [OPTIONS],
319    // matching the format shown by `fmp-agent <group> --help`. Without this,
320    // a bare group invocation shows only the subcommand name (e.g. `market`).
321    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}