Skip to main content

futucli/cmd/
command_catalog.rs

1//! Human-friendly futucli command catalog.
2//!
3//! This is a discovery layer only. Existing root command names remain the
4//! stable CLI contract; grouping metadata must not affect dispatch semantics.
5
6use std::collections::BTreeMap;
7use std::str::FromStr;
8
9use anyhow::{Result, bail};
10use clap::CommandFactory;
11use serde::Serialize;
12use tabled::Tabled;
13
14use crate::cli::Cli;
15use crate::output::OutputFormat;
16
17#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize)]
18#[serde(rename_all = "kebab-case")]
19pub enum CommandGroup {
20    Common,
21    Quote,
22    Trade,
23    Account,
24    Key,
25    System,
26    Research,
27    Advanced,
28}
29
30impl CommandGroup {
31    pub const fn as_str(self) -> &'static str {
32        match self {
33            Self::Common => "common",
34            Self::Quote => "quote",
35            Self::Trade => "trade",
36            Self::Account => "account",
37            Self::Key => "key",
38            Self::System => "system",
39            Self::Research => "research",
40            Self::Advanced => "advanced",
41        }
42    }
43
44    pub const fn label(self) -> &'static str {
45        match self {
46            Self::Common => "常用",
47            Self::Quote => "行情",
48            Self::Trade => "交易",
49            Self::Account => "账户",
50            Self::Key => "密钥/密码",
51            Self::System => "系统/诊断",
52            Self::Research => "财务/研究",
53            Self::Advanced => "高级",
54        }
55    }
56}
57
58impl FromStr for CommandGroup {
59    type Err = anyhow::Error;
60
61    fn from_str(value: &str) -> Result<Self> {
62        let normalized = value.trim().to_ascii_lowercase();
63        match normalized.as_str() {
64            "common" | "basic" | "常用" => Ok(Self::Common),
65            "quote" | "qot" | "行情" => Ok(Self::Quote),
66            "trade" | "trd" | "交易" => Ok(Self::Trade),
67            "account" | "acc" | "账户" => Ok(Self::Account),
68            "key" | "keys" | "auth" | "密钥" | "密码" => Ok(Self::Key),
69            "system" | "sys" | "diagnostic" | "diagnostics" | "诊断" => Ok(Self::System),
70            "research" | "financial" | "finance" | "财务" | "研究" => Ok(Self::Research),
71            "advanced" | "adv" | "debug" | "高级" => Ok(Self::Advanced),
72            _ => bail!(
73                "unknown command group `{value}`; valid groups: common, quote, trade, account, key, system, research, advanced"
74            ),
75        }
76    }
77}
78
79#[derive(Debug, Clone, Copy)]
80pub struct CommandCatalogEntry {
81    pub name: &'static str,
82    pub group: CommandGroup,
83}
84
85#[derive(Debug, Clone, Serialize, Tabled)]
86pub struct CommandCatalogRow {
87    pub group: &'static str,
88    pub label: &'static str,
89    pub command: String,
90    pub summary: String,
91}
92
93include!(concat!(env!("OUT_DIR"), "/generated_cli_catalog.rs"));
94
95pub fn catalog_rows(group: Option<&str>, search: Option<&str>) -> Result<Vec<CommandCatalogRow>> {
96    let group_filter = group.map(CommandGroup::from_str).transpose()?;
97    let search_filter = search.map(|s| s.trim().to_ascii_lowercase());
98    let summaries = command_summaries_by_name();
99
100    let rows = ALL_COMMANDS
101        .iter()
102        .filter(|entry| group_filter.is_none_or(|group| entry.group == group))
103        .filter_map(|entry| {
104            let summary = summaries
105                .get(entry.name)
106                .map(|summary| compact_summary(summary))
107                .unwrap_or_default();
108            let haystack = format!(
109                "{} {} {} {}",
110                entry.name,
111                entry.group.as_str(),
112                entry.group.label(),
113                summary
114            )
115            .to_ascii_lowercase();
116            if search_filter
117                .as_ref()
118                .is_some_and(|needle| !haystack.contains(needle))
119            {
120                return None;
121            }
122            Some(CommandCatalogRow {
123                group: entry.group.as_str(),
124                label: entry.group.label(),
125                command: entry.name.to_string(),
126                summary,
127            })
128        })
129        .collect();
130
131    Ok(rows)
132}
133
134fn command_summaries_by_name() -> BTreeMap<String, String> {
135    Cli::command()
136        .get_subcommands()
137        .map(|cmd| {
138            let summary = cmd.get_about().map(ToString::to_string).unwrap_or_default();
139            (cmd.get_name().to_string(), summary)
140        })
141        .collect()
142}
143
144fn compact_summary(raw: &str) -> String {
145    const MAX_CHARS: usize = 96;
146    let normalized = raw.split_whitespace().collect::<Vec<_>>().join(" ");
147    if normalized.chars().count() <= MAX_CHARS {
148        return normalized;
149    }
150
151    let mut out = normalized.chars().take(MAX_CHARS - 1).collect::<String>();
152    out.push('…');
153    out
154}
155
156pub fn run(group: Option<&str>, search: Option<&str>, output: OutputFormat) -> Result<()> {
157    let rows = catalog_rows(group, search)?;
158    output.print_rows(&rows, &rows)?;
159    Ok(())
160}