1use 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 Usage(String),
27 Api(ApiError),
29 File(String),
31 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
68pub 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 fn client(&self, config: Config) -> Result<Client, ApiError>;
76 fn exec(&self, command: &str, stdin: &[u8]) -> io::Result<i32>;
79 fn terminal(&self) -> Option<Box<dyn Terminal>>;
81}
82
83#[derive(Debug, Clone, Default)]
85pub struct Globals {
86 pub profile: Option<String>,
87 pub lang: Option<String>,
89 pub request_id: Option<String>,
90 pub output: Option<String>,
91 pub quiet: bool,
92}
93
94pub 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 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 pub fn select_profile(&mut self, name: Option<String>) {
133 self.globals.profile = name;
134 self.effective = None;
135 }
136
137 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 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 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 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 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 pub fn say(&mut self, text: &str) -> Result<(), CliError> {
238 writeln!(self.out, "{text}").map_err(|e| {
239 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 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 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
290pub 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
401pub struct Command {
403 pub path: &'static [&'static str],
404 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}