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    /// Writes a line to standard output.
227    pub fn say(&mut self, text: &str) -> Result<(), CliError> {
228        writeln!(self.out, "{text}").map_err(|e| {
229            // Whoever reads the output closed the pipe (`| head`): stop silently, with no error.
230            if e.kind() == io::ErrorKind::BrokenPipe {
231                CliError::Exit(0)
232            } else {
233                CliError::File(format!("failed to write the output: {e}"))
234            }
235        })
236    }
237
238    /// Asks `[y/N]` on a terminal. Off a terminal it does not ask: the caller requires `--yes`.
239    pub fn confirm(&mut self, question: &str) -> Result<bool, CliError> {
240        let _ = write!(self.err, "{question} [y/N] ");
241        let _ = self.err.flush();
242        let mut line = String::new();
243        let _ = self.input.read_line(&mut line);
244        Ok(matches!(
245            line.trim().to_ascii_lowercase().as_str(),
246            "y" | "yes"
247        ))
248    }
249
250    /// Asks for confirmation of an action that deletes: `--yes` is enough; without it, it asks on a terminal and
251    /// refuses (code 2) off one.
252    pub fn require_confirmation(&mut self, yes: bool, question: &str) -> Result<(), CliError> {
253        if yes {
254            return Ok(());
255        }
256        if !self.platform.stdin_is_tty() {
257            return Err(CliError::Usage(format!(
258                "{question}\nOff a terminal the confirmation cannot be asked for: use --yes."
259            )));
260        }
261        if self.confirm(question)? {
262            Ok(())
263        } else {
264            let _ = writeln!(self.err, "Cancelled.");
265            Err(CliError::Exit(EXIT_USAGE))
266        }
267    }
268}
269
270pub(crate) fn read_token_file(path: &str) -> Result<String, CliError> {
271    let text = std::fs::read_to_string(path)
272        .map_err(|e| CliError::File(format!("could not read the token-file {path}: {e}")))?;
273    let token = text.lines().next().unwrap_or_default().trim();
274    if token.is_empty() {
275        return Err(CliError::File(format!("the token-file {path} is empty")));
276    }
277    Ok(token.to_owned())
278}
279
280/// Runs the program with the arguments (without the binary name) and returns the exit code.
281pub fn run(
282    platform: &dyn Platform,
283    args: &[String],
284    out: &mut dyn Write,
285    err: &mut dyn Write,
286    input: &mut dyn BufRead,
287) -> i32 {
288    let table = commands::table();
289    let invocation = match commands::resolve(&table, args) {
290        Ok(i) => i,
291        Err(message) => return report_usage(err, &message),
292    };
293    let command = match invocation {
294        commands::Invocation::Help(topic) => {
295            let text = commands::help_text(&table, &topic);
296            let _ = write!(out, "{text}");
297            return 0;
298        }
299        commands::Invocation::Version => {
300            let _ = writeln!(out, "{}", commands::version_text());
301            return 0;
302        }
303        commands::Invocation::Bare => {
304            let on_terminal = platform.stdin_is_tty() && platform.stdout_is_tty();
305            match table.iter().find(|c| c.path == ["tui"]) {
306                Some(tui) if on_terminal => (tui, args.to_vec()),
307                _ if args.is_empty() => {
308                    let _ = write!(out, "{}", commands::help_text(&table, ""));
309                    return 0;
310                }
311                _ => return report_usage(err, "faltou o comando"),
312            }
313        }
314        commands::Invocation::Run(command, rest) => (command, rest),
315    };
316    let (command, rest) = command;
317    let parsed = match args::parse(&rest, &command.opts) {
318        Ok(p) => p,
319        Err(message) => {
320            return report_usage(
321                err,
322                &format!("{message}\n{}", commands::usage_line(command)),
323            )
324        }
325    };
326    if parsed.flag("help") {
327        let _ = write!(
328            out,
329            "{}",
330            commands::help_text(&table, &command.path.join(" "))
331        );
332        return 0;
333    }
334    if parsed.flag("version") {
335        let _ = writeln!(out, "{}", commands::version_text());
336        return 0;
337    }
338    if parsed.positional.len() < command.min_pos || parsed.positional.len() > command.max_pos {
339        let what = if parsed.positional.len() < command.min_pos {
340            "faltam argumentos"
341        } else {
342            "too many arguments"
343        };
344        return report_usage(err, &format!("{what}\n{}", commands::usage_line(command)));
345    }
346    let globals = Globals {
347        profile: parsed.value("profile").map(str::to_owned),
348        lang: parsed.value("lang").map(str::to_owned),
349        request_id: parsed.value("request-id").map(str::to_owned),
350        output: parsed.value("output").map(str::to_owned),
351        quiet: parsed.flag("quiet"),
352    };
353    if let Some(name) = &globals.output {
354        if let Err(message) = Format::parse(name) {
355            return report_usage(err, &message);
356        }
357    }
358    let mut ctx = Ctx::new(platform, out, err, input, globals);
359    match (command.run)(&mut ctx, &parsed) {
360        Ok(()) => 0,
361        Err(error) => report(&mut ctx, error),
362    }
363}
364
365fn report_usage(err: &mut dyn Write, message: &str) -> i32 {
366    let _ = writeln!(
367        err,
368        "taba-cli: {message}\nUse `taba-cli help` to see the commands."
369    );
370    EXIT_USAGE
371}
372
373fn report(ctx: &mut Ctx<'_>, error: CliError) -> i32 {
374    let code = error.code();
375    match error {
376        CliError::Usage(message) => {
377            let _ = writeln!(ctx.err, "taba-cli: {message}");
378        }
379        CliError::File(message) => {
380            let _ = writeln!(ctx.err, "taba-cli: {message}");
381        }
382        CliError::Exit(_) => {}
383        CliError::Api(e) => {
384            let json = ctx.json_chosen();
385            let _ = output::write_api_error(ctx.err, &e, json);
386        }
387    }
388    code
389}
390
391/// For the commands: the options, a command name and a list of options inside a table.
392pub struct Command {
393    pub path: &'static [&'static str],
394    /// The positional arguments, for the usage line (`<name>`).
395    pub args: &'static str,
396    pub summary: &'static str,
397    pub min_pos: usize,
398    pub max_pos: usize,
399    pub opts: Vec<Opt>,
400    pub run: fn(&mut Ctx<'_>, &Parsed) -> Result<(), CliError>,
401}