Skip to main content

taba_cli/
app.rs

1//! The core of the program: the context of a run, the errors and exit codes, and the dispatch
2//! of the command. Everything that touches the system (environment, terminal, network, processes) goes through `Platform`,
3//! so the tests can swap in a fake one.
4
5use std::io::{self, BufRead, Write};
6
7use taba_client::{Client, Config, Error as ApiError};
8
9use crate::args::{self, Opt, Parsed};
10use crate::commands;
11use crate::output::{self, Format};
12use crate::profile::{self, ConfigFile, Os};
13use crate::settings::{self, Effective, TokenSource};
14use crate::tui::Terminal;
15
16pub const EXIT_API: i32 = 1;
17pub const EXIT_USAGE: i32 = 2;
18pub const EXIT_CONNECTION: i32 = 3;
19pub const EXIT_TIMEOUT: i32 = 4;
20pub const EXIT_FILE: i32 = 5;
21pub const EXIT_EMPTY: i32 = 6;
22
23#[derive(Debug)]
24pub enum CliError {
25    /// Wrong usage: unknown command or option, missing argument, invalid value.
26    Usage(String),
27    /// The API answered with an error, or the client could not talk to it.
28    Api(ApiError),
29    /// Unreadable file (`--file`, the `token-file`, the `ca-file` or `config.yml`).
30    File(String),
31    /// Ends with this code without printing anything else (the message already came out).
32    Exit(i32),
33}
34
35impl CliError {
36    pub fn usage(message: impl Into<String>) -> CliError {
37        CliError::Usage(message.into())
38    }
39
40    pub fn code(&self) -> i32 {
41        match self {
42            CliError::Usage(_) => EXIT_USAGE,
43            CliError::File(_) => EXIT_FILE,
44            CliError::Exit(code) => *code,
45            CliError::Api(e) => match e.code.as_str() {
46                _ if e.status > 0 => EXIT_API,
47                "client.network" => EXIT_CONNECTION,
48                "client.timeout" => EXIT_TIMEOUT,
49                "client.invalid-config" => EXIT_USAGE,
50                _ => EXIT_API,
51            },
52        }
53    }
54}
55
56impl From<ApiError> for CliError {
57    fn from(e: ApiError) -> CliError {
58        CliError::Api(e)
59    }
60}
61
62impl From<String> for CliError {
63    fn from(message: String) -> CliError {
64        CliError::Usage(message)
65    }
66}
67
68/// What the program asks of the system.
69pub trait Platform {
70    fn env(&self, name: &str) -> Option<String>;
71    fn os(&self) -> Os;
72    fn stdin_is_tty(&self) -> bool;
73    fn stdout_is_tty(&self) -> bool;
74    /// The API client for this configuration.
75    fn client(&self, config: Config) -> Result<Client, ApiError>;
76    /// Runs `command` through the system's command interpreter, with `stdin` on standard input, and
77    /// returns the exit code.
78    fn exec(&self, command: &str, stdin: &[u8]) -> io::Result<i32>;
79    /// The terminal in raw mode for the interactive screen, if there is one.
80    fn terminal(&self) -> Option<Box<dyn Terminal>>;
81}
82
83/// The global options read from the command line.
84#[derive(Debug, Clone, Default)]
85pub struct Globals {
86    pub profile: Option<String>,
87    /// `--lang`: the language of the interactive interface.
88    pub lang: Option<String>,
89    pub request_id: Option<String>,
90    pub output: Option<String>,
91    pub quiet: bool,
92}
93
94/// A run: the platform, the outputs and the configuration, read only when a command needs it.
95pub struct Ctx<'a> {
96    pub platform: &'a dyn Platform,
97    pub out: &'a mut dyn Write,
98    pub err: &'a mut dyn Write,
99    pub input: &'a mut dyn BufRead,
100    pub globals: Globals,
101    effective: Option<Effective>,
102}
103
104impl<'a> Ctx<'a> {
105    pub fn new(
106        platform: &'a dyn Platform,
107        out: &'a mut dyn Write,
108        err: &'a mut dyn Write,
109        input: &'a mut dyn BufRead,
110        globals: Globals,
111    ) -> Ctx<'a> {
112        Ctx {
113            platform,
114            out,
115            err,
116            input,
117            globals,
118            effective: None,
119        }
120    }
121
122    pub fn env(&self, name: &str) -> Option<String> {
123        self.platform.env(name)
124    }
125
126    /// The path of `config.yml`.
127    pub fn config_path(&self) -> Result<std::path::PathBuf, CliError> {
128        profile::config_path(self.platform.os(), &|k| self.platform.env(k)).map_err(CliError::File)
129    }
130
131    /// Makes `name` the profile the next calls use, as `--profile` would.
132    pub fn select_profile(&mut self, name: Option<String>) {
133        self.globals.profile = name;
134        self.effective = None;
135    }
136
137    /// The profiles file (empty if it does not exist yet).
138    pub fn load_config(&self) -> Result<ConfigFile, CliError> {
139        profile::load(&self.config_path()?).map_err(CliError::File)
140    }
141
142    pub fn save_config(&self, file: &ConfigFile) -> Result<(), CliError> {
143        profile::save(&self.config_path()?, file).map_err(CliError::File)
144    }
145
146    /// The effective configuration: variables, chosen profile and built-in values.
147    pub fn settings(&mut self) -> Result<&Effective, CliError> {
148        if self.effective.is_none() {
149            let file = self.load_config()?;
150            let platform = self.platform;
151            let effective =
152                settings::resolve(&file, self.globals.profile.as_deref(), &|k| platform.env(k))
153                    .map_err(CliError::Usage)?;
154            self.effective = Some(effective);
155        }
156        Ok(self.effective.as_ref().expect("it was just defined"))
157    }
158
159    /// `true` when the person chose `json` (on the command line, in the environment or in the profile), and not
160    /// when it is only the built-in value: only then does the error come out as the server's `ErrorResponse`.
161    pub fn json_chosen(&mut self) -> bool {
162        if let Some(name) = &self.globals.output {
163            return name == "json";
164        }
165        match self.settings() {
166            Ok(s) => {
167                let setting = s.setting("output");
168                setting.source != crate::settings::Source::Default
169                    && setting.value.as_deref() == Some("json")
170            }
171            Err(_) => false,
172        }
173    }
174
175    pub fn format(&mut self) -> Result<Format, CliError> {
176        let flag = self.globals.output.clone();
177        let name = match flag {
178            Some(name) => name,
179            None => self.settings()?.get("output").unwrap_or("json").to_owned(),
180        };
181        Format::parse(&name).map_err(CliError::Usage)
182    }
183
184    fn client_config(&mut self, url: String, with_token: bool) -> Result<Config, CliError> {
185        let (token, ca_file, connect, total, attempts) = {
186            let s = self.settings()?;
187            let token = if with_token {
188                match s.token_source() {
189                    TokenSource::None => None,
190                    TokenSource::Text(t) => Some(t.to_owned()),
191                    TokenSource::File(path) => Some(read_token_file(path)?),
192                }
193            } else {
194                None
195            };
196            (
197                token,
198                s.get("ca-file").map(str::to_owned),
199                s.number("connect-timeout"),
200                s.number("timeout"),
201                s.number("max-attempts"),
202            )
203        };
204        let mut config = Config::new(url);
205        config.token = token;
206        config.connect_timeout = std::time::Duration::from_secs(connect.max(1));
207        config.request_timeout = std::time::Duration::from_secs(total.max(1));
208        config.max_attempts = attempts.max(1) as u32;
209        config.user_agent = format!("taba-cli/{}", env!("CARGO_PKG_VERSION"));
210        if let Some(path) = ca_file {
211            config.ca_pem =
212                Some(std::fs::read(&path).map_err(|e| {
213                    CliError::File(format!("could not read the ca-file {path}: {e}"))
214                })?);
215        }
216        Ok(config)
217    }
218
219    /// The client API client (with the profile's token).
220    pub fn api_client(&mut self) -> Result<Client, CliError> {
221        let url = self.settings()?.url().to_owned();
222        let config = self.client_config(url, true)?;
223        Ok(self.platform.client(config)?)
224    }
225
226    /// The operations API client: no token. `attempts` overrides the profile's `max-attempts`.
227    pub fn ops_client(&mut self, attempts: Option<u32>) -> Result<Client, CliError> {
228        let url = self.settings()?.ops_url();
229        let mut config = self.client_config(url, false)?;
230        if let Some(n) = attempts {
231            config.max_attempts = n;
232        }
233        Ok(self.platform.client(config)?)
234    }
235
236    /// Writes a line to standard output.
237    pub fn say(&mut self, text: &str) -> Result<(), CliError> {
238        writeln!(self.out, "{text}").map_err(|e| {
239            // Whoever reads the output closed the pipe (`| head`): stop silently, with no error.
240            if e.kind() == io::ErrorKind::BrokenPipe {
241                CliError::Exit(0)
242            } else {
243                CliError::File(format!("failed to write the output: {e}"))
244            }
245        })
246    }
247
248    /// Asks `[y/N]` on a terminal. Off a terminal it does not ask: the caller requires `--yes`.
249    pub fn confirm(&mut self, question: &str) -> Result<bool, CliError> {
250        let _ = write!(self.err, "{question} [y/N] ");
251        let _ = self.err.flush();
252        let mut line = String::new();
253        let _ = self.input.read_line(&mut line);
254        Ok(matches!(
255            line.trim().to_ascii_lowercase().as_str(),
256            "y" | "yes"
257        ))
258    }
259
260    /// Asks for confirmation of an action that deletes: `--yes` is enough; without it, it asks on a terminal and
261    /// refuses (code 2) off one.
262    pub fn require_confirmation(&mut self, yes: bool, question: &str) -> Result<(), CliError> {
263        if yes {
264            return Ok(());
265        }
266        if !self.platform.stdin_is_tty() {
267            return Err(CliError::Usage(format!(
268                "{question}\nOff a terminal the confirmation cannot be asked for: use --yes."
269            )));
270        }
271        if self.confirm(question)? {
272            Ok(())
273        } else {
274            let _ = writeln!(self.err, "Cancelled.");
275            Err(CliError::Exit(EXIT_USAGE))
276        }
277    }
278}
279
280pub(crate) fn read_token_file(path: &str) -> Result<String, CliError> {
281    let text = std::fs::read_to_string(path)
282        .map_err(|e| CliError::File(format!("could not read the token-file {path}: {e}")))?;
283    let token = text.lines().next().unwrap_or_default().trim();
284    if token.is_empty() {
285        return Err(CliError::File(format!("the token-file {path} is empty")));
286    }
287    Ok(token.to_owned())
288}
289
290/// Runs the program with the arguments (without the binary name) and returns the exit code.
291pub fn run(
292    platform: &dyn Platform,
293    args: &[String],
294    out: &mut dyn Write,
295    err: &mut dyn Write,
296    input: &mut dyn BufRead,
297) -> i32 {
298    let table = commands::table();
299    let invocation = match commands::resolve(&table, args) {
300        Ok(i) => i,
301        Err(message) => return report_usage(err, &message),
302    };
303    let command = match invocation {
304        commands::Invocation::Help(topic) => {
305            let text = commands::help_text(&table, &topic);
306            let _ = write!(out, "{text}");
307            return 0;
308        }
309        commands::Invocation::Version => {
310            let _ = writeln!(out, "{}", commands::version_text());
311            return 0;
312        }
313        commands::Invocation::Bare => {
314            let on_terminal = platform.stdin_is_tty() && platform.stdout_is_tty();
315            match table.iter().find(|c| c.path == ["tui"]) {
316                Some(tui) if on_terminal => (tui, args.to_vec()),
317                _ if args.is_empty() => {
318                    let _ = write!(out, "{}", commands::help_text(&table, ""));
319                    return 0;
320                }
321                _ => return report_usage(err, "faltou o comando"),
322            }
323        }
324        commands::Invocation::Run(command, rest) => (command, rest),
325    };
326    let (command, rest) = command;
327    let parsed = match args::parse(&rest, &command.opts) {
328        Ok(p) => p,
329        Err(message) => {
330            return report_usage(
331                err,
332                &format!("{message}\n{}", commands::usage_line(command)),
333            )
334        }
335    };
336    if parsed.flag("help") {
337        let _ = write!(
338            out,
339            "{}",
340            commands::help_text(&table, &command.path.join(" "))
341        );
342        return 0;
343    }
344    if parsed.flag("version") {
345        let _ = writeln!(out, "{}", commands::version_text());
346        return 0;
347    }
348    if parsed.positional.len() < command.min_pos || parsed.positional.len() > command.max_pos {
349        let what = if parsed.positional.len() < command.min_pos {
350            "faltam argumentos"
351        } else {
352            "too many arguments"
353        };
354        return report_usage(err, &format!("{what}\n{}", commands::usage_line(command)));
355    }
356    let globals = Globals {
357        profile: parsed.value("profile").map(str::to_owned),
358        lang: parsed.value("lang").map(str::to_owned),
359        request_id: parsed.value("request-id").map(str::to_owned),
360        output: parsed.value("output").map(str::to_owned),
361        quiet: parsed.flag("quiet"),
362    };
363    if let Some(name) = &globals.output {
364        if let Err(message) = Format::parse(name) {
365            return report_usage(err, &message);
366        }
367    }
368    let mut ctx = Ctx::new(platform, out, err, input, globals);
369    match (command.run)(&mut ctx, &parsed) {
370        Ok(()) => 0,
371        Err(error) => report(&mut ctx, error),
372    }
373}
374
375fn report_usage(err: &mut dyn Write, message: &str) -> i32 {
376    let _ = writeln!(
377        err,
378        "taba-cli: {message}\nUse `taba-cli help` to see the commands."
379    );
380    EXIT_USAGE
381}
382
383fn report(ctx: &mut Ctx<'_>, error: CliError) -> i32 {
384    let code = error.code();
385    match error {
386        CliError::Usage(message) => {
387            let _ = writeln!(ctx.err, "taba-cli: {message}");
388        }
389        CliError::File(message) => {
390            let _ = writeln!(ctx.err, "taba-cli: {message}");
391        }
392        CliError::Exit(_) => {}
393        CliError::Api(e) => {
394            let json = ctx.json_chosen();
395            let _ = output::write_api_error(ctx.err, &e, json);
396        }
397    }
398    code
399}
400
401/// For the commands: the options, a command name and a list of options inside a table.
402pub struct Command {
403    pub path: &'static [&'static str],
404    /// The positional arguments, for the usage line (`<name>`).
405    pub args: &'static str,
406    pub summary: &'static str,
407    pub min_pos: usize,
408    pub max_pos: usize,
409    pub opts: Vec<Opt>,
410    pub run: fn(&mut Ctx<'_>, &Parsed) -> Result<(), CliError>,
411}