use std::io::{self, Write};
mod args;
mod commands;
mod dispatch;
mod groups;
mod help;
mod output;
mod schema;
#[cfg(test)]
mod tests;
pub use args::Cli;
use clap::CommandFactory;
use reqwest::Url;
use serde_json::{Value, json};
use commands::execute;
use output::render_output;
use crate::client::FmpClient;
use crate::error::{Error, Result};
fn print_stdout_line(output: &str) {
if let Err(e) = writeln!(io::stdout(), "{output}")
&& e.kind() != io::ErrorKind::BrokenPipe
{
panic!("failed printing to stdout: {e}");
}
}
pub async fn run(cli: Cli) -> Result<()> {
if let args::Command::Help { command } = &cli.command {
if let Some(topic) = command {
print_stdout_line(help_topic_text(topic));
} else {
print_group_help("help")?;
}
return Ok(());
}
if let args::Command::Schema = &cli.command {
let data = schema::schema_payload();
let output = serde_json::to_string(&data).map_err(crate::error::Error::Json)?;
print_stdout_line(&output);
return Ok(());
}
if let args::Command::Commands { grouped } = &cli.command {
let cmd = <Cli as CommandFactory>::command();
let output = if *grouped {
grouped_commands(&cmd)
} else {
leaf_commands(&cmd)
};
for line in output {
print_stdout_line(&line);
}
return Ok(());
}
if let args::Command::Completions { shell } = &cli.command {
let mut cmd = <Cli as CommandFactory>::command();
let mut buf = Vec::new();
clap_complete::generate(shell.to_owned(), &mut cmd, "fmp-agent", &mut buf);
let output = String::from_utf8(buf).expect("completions output is valid UTF-8");
print_stdout_line(&output);
return Ok(());
}
if let args::Command::Doctor = &cli.command {
let output = serde_json::to_string(&doctor_report(&cli.base_url, cli.api_key.as_deref()))
.map_err(crate::error::Error::Json)?;
print_stdout_line(&output);
return Ok(());
}
if let Some(group_name) = bare_group_name(&cli.command) {
print_group_help(group_name)?;
return Ok(());
}
let Some(api_key) = cli.api_key.filter(|key| !key.trim().is_empty()) else {
return Err(Error::MissingApiKey);
};
let client = FmpClient::with_base_url(api_key, &cli.base_url)?;
let payload = execute(&client, &cli.command).await?;
if cli.strict_empty {
payload.reject_strict_empty()?;
}
if let Some(output) = render_output(payload)? {
print_stdout_line(&output);
}
Ok(())
}
fn doctor_report(base_url: &str, api_key: Option<&str>) -> Value {
let api_key_configured = api_key.is_some_and(|key| !key.trim().is_empty());
let base_url_display = display_base_url(base_url);
let base_url_error = FmpClient::with_base_url("", base_url)
.err()
.map(|error| json!({ "kind": error.kind(), "message": error.to_string() }));
let base_url_valid = base_url_error.is_none();
let ok = api_key_configured && base_url_valid;
json!({
"ok": ok,
"version": env!("CARGO_PKG_VERSION"),
"base_url": {
"value": base_url_display,
"valid": base_url_valid,
"error": base_url_error,
},
"api_key": {
"configured": api_key_configured,
"status": if api_key_configured { "ok" } else { "missing" },
},
"live_connectivity": {
"checked": false,
"status": "skipped",
"reason": "doctor performs local checks only and does not consume FMP API quota",
},
})
}
fn display_base_url(base_url: &str) -> String {
let Ok(mut url) = Url::parse(base_url) else {
return "<invalid URL>".to_owned();
};
let _ = url.set_username("");
let _ = url.set_password(None);
url.set_query(None);
url.set_fragment(None);
url.to_string()
}
fn bare_group_name(command: &args::Command) -> Option<&'static str> {
match command {
args::Command::Company { command: None } => Some("company"),
args::Command::Market { command: None } => Some("market"),
args::Command::Fundamentals { command: None } => Some("fundamentals"),
args::Command::Analyst { command: None } => Some("analyst"),
args::Command::Insider { command: None } => Some("insider"),
args::Command::Calendar { command: None } => Some("calendar"),
args::Command::MacroEcon { command: None } => Some("macro"),
args::Command::Technical { command: None } => Some("technical"),
args::Command::Sec { command: None } => Some("sec"),
args::Command::Etf { command: None } => Some("etf"),
args::Command::Crypto { command: None } => Some("crypto"),
args::Command::Forex { command: None } => Some("forex"),
args::Command::News { command: None } => Some("news"),
_ => None,
}
}
fn help_topic_text(topic: &args::HelpTopic) -> &'static str {
match topic {
args::HelpTopic::Environment => help::HELP_ENVIRONMENT_LONG,
args::HelpTopic::ExitCodes => help::HELP_EXIT_CODES_LONG,
args::HelpTopic::Schema => help::HELP_SCHEMA_LONG,
args::HelpTopic::Examples => help::HELP_EXAMPLES_LONG,
}
}
fn leaf_commands(cmd: &clap::Command) -> Vec<String> {
let mut leaves = Vec::new();
for sub in cmd.get_subcommands() {
let real_children: Vec<_> = sub
.get_subcommands()
.filter(|c| c.get_name() != "help")
.collect();
if sub.get_name() == "help" && real_children.is_empty() {
continue;
}
if real_children.is_empty() {
leaves.push(sub.get_name().to_owned());
} else {
for child in leaf_commands(sub) {
leaves.push(format!("{} {child}", sub.get_name()));
}
}
}
leaves.sort();
leaves
}
fn grouped_commands(cmd: &clap::Command) -> Vec<String> {
let mut lines = Vec::new();
for sub in cmd.get_subcommands() {
let children: Vec<_> = sub
.get_subcommands()
.filter(|c| c.get_name() != "help")
.collect();
if sub.get_name() == "help" && children.is_empty() {
continue;
}
if children.is_empty() {
continue;
}
lines.push(sub.get_name().to_owned());
let max_width = children
.iter()
.map(|c| format!("{} {}", sub.get_name(), c.get_name()).len())
.max()
.unwrap_or(0);
for child in &children {
let full_path = format!("{} {}", sub.get_name(), child.get_name());
let about = child.get_about().map(|a| a.to_string()).unwrap_or_default();
lines.push(format!(" {full_path:<max_width$} {about}"));
}
lines.push(String::new());
}
let leaves: Vec<&clap::Command> = cmd
.get_subcommands()
.filter(|s| {
s.get_name() != "help"
&& s.get_subcommands()
.filter(|c| c.get_name() != "help")
.count()
== 0
})
.collect();
const DISCOVERY: &[&str] = &["doctor", "schema", "commands", "completions"];
let discovery_leaves: Vec<_> = leaves
.iter()
.filter(|l| DISCOVERY.contains(&l.get_name()))
.copied()
.collect();
let alias_leaves: Vec<_> = leaves
.into_iter()
.filter(|l| !DISCOVERY.contains(&l.get_name()))
.collect();
if !discovery_leaves.is_empty() {
lines.push("discovery".to_owned());
let max_width = discovery_leaves
.iter()
.map(|l| l.get_name().len())
.max()
.unwrap_or(0);
for leaf in &discovery_leaves {
let about = leaf.get_about().map(|a| a.to_string()).unwrap_or_default();
lines.push(format!(" {:<max_width$} {about}", leaf.get_name()));
}
lines.push(String::new());
}
if !alias_leaves.is_empty() {
lines.push("aliases".to_owned());
let max_width = alias_leaves
.iter()
.map(|l| l.get_name().len())
.max()
.unwrap_or(0);
for leaf in &alias_leaves {
let about = leaf.get_about().map(|a| a.to_string()).unwrap_or_default();
lines.push(format!(" {:<max_width$} {about}", leaf.get_name()));
}
lines.push(String::new());
}
lines
}
pub(crate) fn print_group_help(group_name: &str) -> Result<()> {
let mut command = <Cli as CommandFactory>::command();
let group = command
.find_subcommand_mut(group_name)
.expect("group name must match a registered Clap subcommand");
let help = group
.clone()
.override_usage(format!("fmp-agent {group_name} [OPTIONS] [COMMAND]"))
.render_help()
.to_string();
print_stdout_line(&help);
Ok(())
}