Skip to main content

taba_cli/commands/
mod.rs

1//! The command table: what each one accepts, the help and the choice of command by the words of the
2//! arguments.
3
4use crate::app::Command;
5use crate::args::GLOBAL;
6
7mod message;
8mod ops;
9mod profile;
10mod token;
11mod topic;
12mod tui;
13
14/// The program's version and that of the API contract it implements.
15/// The commit comes from `TABA_GIT_SHA`, injected by the pipeline build; a local build has none.
16pub fn version_text() -> String {
17    let text = format!(
18        "taba-cli {} (API contract {})",
19        env!("CARGO_PKG_VERSION"),
20        taba_client::CONTRACT_VERSION
21    );
22    match option_env!("TABA_GIT_SHA") {
23        Some(sha) => format!("{text} commit {sha}"),
24        None => text,
25    }
26}
27
28pub fn table() -> Vec<Command> {
29    let mut all = Vec::new();
30    all.extend(topic::commands());
31    all.extend(message::commands());
32    all.extend(token::commands());
33    all.extend(ops::commands());
34    all.extend(profile::commands());
35    all.extend(tui::commands());
36    all
37}
38
39pub enum Invocation<'a> {
40    /// `help [command]` or `--help` with no command: the help text for the subject (empty = general).
41    Help(String),
42    Version,
43    /// No command word at all (nothing, or only options): the interface when there is a terminal.
44    Bare,
45    /// The command and the arguments left over (without the command words).
46    Run(&'a Command, Vec<String>),
47}
48
49/// Finds the command by the first words. Global options with a value (`--profile x`, `-o x`)
50/// may come first: their values do not count as words.
51pub fn resolve<'a>(table: &'a [Command], args: &[String]) -> Result<Invocation<'a>, String> {
52    let mut words: Vec<(usize, &str)> = Vec::new();
53    let mut i = 0;
54    while i < args.len() && words.len() < 2 {
55        let arg = args[i].as_str();
56        if arg == "--" {
57            break;
58        }
59        if let Some(rest) = arg.strip_prefix('-').filter(|r| !r.is_empty()) {
60            let long = rest.strip_prefix('-');
61            let takes = GLOBAL.iter().any(|o| {
62                o.takes_value
63                    && match long {
64                        Some(name) => o.name == name,
65                        None => rest.len() == 1 && o.short.is_some_and(|s| rest.starts_with(s)),
66                    }
67            });
68            i += if takes { 2 } else { 1 };
69            continue;
70        }
71        words.push((i, arg));
72        i += 1;
73    }
74    let wants_help = args.iter().any(|a| a == "--help");
75    if words.is_empty() {
76        if args.iter().any(|a| a == "--version") {
77            return Ok(Invocation::Version);
78        }
79        return if wants_help {
80            Ok(Invocation::Help(String::new()))
81        } else {
82            Ok(Invocation::Bare)
83        };
84    }
85    if words[0].1 == "help" {
86        let topic: Vec<&str> = args
87            .iter()
88            .map(String::as_str)
89            .filter(|a| !a.starts_with('-'))
90            .skip(1)
91            .collect();
92        return Ok(Invocation::Help(topic.join(" ")));
93    }
94    let find = |path: &[&str]| table.iter().find(|c| c.path == path);
95    let known_group = table
96        .iter()
97        .any(|c| c.path.len() == 2 && c.path[0] == words[0].1);
98    let (command, used): (&Command, Vec<usize>) =
99        if let Some(second) = words.get(1).filter(|_| known_group) {
100            match find(&[words[0].1, second.1]) {
101                Some(c) => (c, vec![words[0].0, second.0]),
102                None => {
103                    let subs: Vec<&str> = table
104                        .iter()
105                        .filter(|c| c.path.len() == 2 && c.path[0] == words[0].1)
106                        .map(|c| c.path[1])
107                        .collect();
108                    return Err(format!(
109                        "`{} {}` is not a command ({} {})",
110                        words[0].1,
111                        second.1,
112                        words[0].1,
113                        subs.join(" | ")
114                    ));
115                }
116            }
117        } else if let Some(c) = find(&[words[0].1]) {
118            (c, vec![words[0].0])
119        } else if known_group {
120            if wants_help {
121                return Ok(Invocation::Help(words[0].1.to_owned()));
122            }
123            let subs: Vec<&str> = table
124                .iter()
125                .filter(|c| c.path.len() == 2 && c.path[0] == words[0].1)
126                .map(|c| c.path[1])
127                .collect();
128            return Err(format!(
129                "missing the subcommand of `{}` ({})",
130                words[0].1,
131                subs.join(" | ")
132            ));
133        } else {
134            return Err(format!("`{}` is not a command", words[0].1));
135        };
136    let rest: Vec<String> = args
137        .iter()
138        .enumerate()
139        .filter(|(n, _)| !used.contains(n))
140        .map(|(_, a)| a.clone())
141        .collect();
142    Ok(Invocation::Run(command, rest))
143}
144
145pub fn usage_line(c: &Command) -> String {
146    let mut line = format!("usage: taba-cli {}", c.path.join(" "));
147    if !c.args.is_empty() {
148        line.push(' ');
149        line.push_str(c.args);
150    }
151    if !c.opts.is_empty() {
152        line.push_str(" [options]");
153    }
154    line
155}
156
157pub fn help_text(table: &[Command], topic: &str) -> String {
158    let words: Vec<&str> = topic.split_whitespace().collect();
159    let matching: Vec<&Command> = table
160        .iter()
161        .filter(|c| {
162            !words.is_empty() && c.path.len() >= words.len() && c.path[..words.len()] == words[..]
163        })
164        .collect();
165    let mut out = String::new();
166    if words.is_empty() {
167        out.push_str(&format!(
168            "{}\n\nusage: taba-cli <command> [arguments] [options]\n\nCommands:\n",
169            version_text()
170        ));
171        for c in table {
172            out.push_str(&format!(
173                "  {:<28} {}\n",
174                format!("{} {}", c.path.join(" "), c.args).trim_end(),
175                c.summary
176            ));
177        }
178        out.push_str("  help [command]               Shows the help of a command.\n  version                      Shows the program version and the contract's.\n");
179        out.push_str("\nOptions of every command:\n  --profile <name>    Connection profile (default: the file's default profile)\n  --lang <code>       Interface language: en-US or pt-BR (taba-cli tui)\n  -o, --output <fmt>  json, table or raw\n  -q, --quiet         Prints nothing on success\n  --request-id <id>   Goes in X-Request-Id\n  --help, --version\n");
180        return out;
181    }
182    if matching.is_empty() {
183        return format!("There is no help for `{topic}`. Use `taba-cli help`.\n");
184    }
185    for c in matching {
186        out.push_str(&format!("{}\n  {}\n", usage_line(c), c.summary));
187        let mut opts: Vec<String> = c
188            .opts
189            .iter()
190            .map(|o| {
191                if o.takes_value {
192                    format!("--{} <value>", o.name)
193                } else {
194                    format!("--{}", o.name)
195                }
196            })
197            .collect();
198        opts.extend([
199            "--profile <name>".into(),
200            "-o <formato>".into(),
201            "-q".into(),
202            "--request-id <id>".into(),
203        ]);
204        out.push_str(&format!("  options: {}\n\n", opts.join(", ")));
205    }
206    out
207}
208
209// ---------------------------------------------------------------------------------------------
210// Command helpers
211// ---------------------------------------------------------------------------------------------
212
213use crate::app::{CliError, Ctx};
214use crate::args::Parsed;
215use crate::output;
216use taba_client::json::{self, Fields};
217use taba_client::{Call, Client, Error as ApiError};
218
219/// A new call, already with the `--request-id`.
220pub(crate) fn call(ctx: &Ctx<'_>, method: &'static str, path: impl Into<String>) -> Call {
221    let mut call = Call::new(method, path);
222    call.request_id = ctx.globals.request_id.clone();
223    call
224}
225
226/// Prints the text of a response in the chosen format (nothing with `--quiet`).
227pub(crate) fn emit(ctx: &mut Ctx<'_>, text: &str) -> Result<(), CliError> {
228    if ctx.globals.quiet {
229        return Ok(());
230    }
231    let format = ctx.format()?;
232    let rendered = output::render(text, format);
233    ctx.say(&rendered)
234}
235
236pub(crate) fn number(p: &Parsed, name: &str) -> Result<Option<u64>, CliError> {
237    p.number(name).map_err(CliError::Usage)
238}
239
240/// The `key=value` pairs of a repeatable option.
241pub(crate) fn pairs(p: &Parsed, name: &str) -> Result<Vec<(String, String)>, CliError> {
242    p.values(name)
243        .into_iter()
244        .map(|raw| match raw.split_once('=') {
245            Some((k, v)) if !k.is_empty() => Ok((k.to_owned(), v.to_owned())),
246            _ => Err(CliError::Usage(format!(
247                "--{name}: `{raw}` must have the form key=value"
248            ))),
249        })
250        .collect()
251}
252
253pub(crate) fn one_of(p: &Parsed, name: &str, allowed: &[&str]) -> Result<Option<String>, CliError> {
254    match p.value(name) {
255        None => Ok(None),
256        Some(v) if allowed.contains(&v) => Ok(Some(v.to_owned())),
257        Some(v) => Err(CliError::Usage(format!(
258            "--{name}: `{v}` is not valid (use {})",
259            allowed.join(" or ")
260        ))),
261    }
262}
263
264/// A paginated list: a page as the server returned it, or, with `all`, all the pages together
265/// in a single `{"items":[...]}`. Returns the JSON text.
266pub(crate) fn paged(
267    ctx: &Ctx<'_>,
268    client: &Client,
269    path: &str,
270    query: Vec<(String, String)>,
271    all: bool,
272    first_cursor: Option<String>,
273) -> Result<String, CliError> {
274    let mut cursor = first_cursor;
275    let mut collected: Vec<String> = Vec::new();
276    loop {
277        let mut c = call(ctx, "GET", path);
278        c.query = query.clone();
279        if let Some(cursor) = &cursor {
280            c.query.retain(|(k, _)| k != "cursor");
281            c.query.push(("cursor".to_owned(), cursor.clone()));
282        }
283        let text = client.call(&c)?.text();
284        if !all {
285            return Ok(text);
286        }
287        let invalid =
288            |m: String| CliError::Api(ApiError::from_message("client.invalid-response", m));
289        let fields = Fields::parse(&text).map_err(|e| invalid(format!("invalid page: {e}")))?;
290        let items = json::array_items(fields.raw("items").unwrap_or("[]"))
291            .map_err(|e| invalid(format!("invalid items: {e}")))?;
292        collected.extend(items.into_iter().map(str::to_owned));
293        match fields.string("nextCursor") {
294            Some(next) => cursor = Some(next),
295            None => return Ok(format!("{{\"items\":[{}]}}", collected.join(","))),
296        }
297    }
298}