use std::io::{self, BufRead, IsTerminal, Write};
use rainier_support::{Error, Result};
pub fn table(headers: &[&str], rows: &[Vec<String>]) {
print!("{}", table_to_string(headers, rows));
}
pub fn table_to_string(headers: &[&str], rows: &[Vec<String>]) -> String {
let columns = headers.len().max(rows.iter().map(Vec::len).max().unwrap_or(0));
if columns == 0 {
return String::new();
}
let mut widths = vec![0usize; columns];
for (index, header) in headers.iter().enumerate() {
widths[index] = width(header);
}
for row in rows {
for (index, cell) in row.iter().enumerate() {
widths[index] = widths[index].max(width(cell));
}
}
let rule = {
let mut rule = String::from("+");
for width in &widths {
rule.push_str(&"-".repeat(width + 2));
rule.push('+');
}
rule.push('\n');
rule
};
let mut out = String::new();
out.push_str(&rule);
if !headers.is_empty() {
out.push_str(&render_row(headers.iter().map(|h| h.to_string()), &widths));
out.push_str(&rule);
}
for row in rows {
out.push_str(&render_row(row.iter().cloned(), &widths));
}
if !rows.is_empty() {
out.push_str(&rule);
}
out
}
pub fn ask(question: &str) -> Result<String> {
prompt(&format!("{question} "))?;
read_line()
}
pub fn ask_with_default(question: &str, default: &str) -> Result<String> {
prompt(&format!("{question} [{default}]: "))?;
let answer = read_line()?;
Ok(if answer.is_empty() { default.to_string() } else { answer })
}
pub fn secret(question: &str) -> Result<String> {
prompt(&format!("{question} "))?;
let secret = rpassword::read_password().map_err(|e| match e.kind() {
io::ErrorKind::UnexpectedEof => closed_stdin(),
_ => Error::internal(format!("could not read without echoing: {e}")),
})?;
println!();
Ok(secret.trim().to_string())
}
pub fn confirm(question: &str, default: bool) -> Result<bool> {
let hint = if default { "Y/n" } else { "y/N" };
loop {
prompt(&format!("{question} [{hint}]: "))?;
match read_line()?.to_ascii_lowercase().as_str() {
"" => return Ok(default),
"y" | "yes" => return Ok(true),
"n" | "no" => return Ok(false),
_ => println!("Please answer `y` or `n`."),
}
}
}
pub fn confirm_by_typing(warning: &str, phrase: &str) -> Result<bool> {
if !io::stdin().is_terminal() {
return Err(Error::internal(format!(
"this needs `{phrase}` typed at a terminal to confirm, and there is not one \
— pass --force if the command should run unattended"
)));
}
println!("{warning}");
prompt(&format!("Type `{phrase}` to continue: "))?;
let typed = read_line()?;
if typed == phrase {
Ok(true)
} else {
println!("That did not match. Nothing was done.");
Ok(false)
}
}
pub fn is_interactive() -> bool {
io::stdin().is_terminal() && io::stdout().is_terminal()
}
fn width(text: &str) -> usize {
text.chars().count()
}
fn render_row(cells: impl Iterator<Item = String>, widths: &[usize]) -> String {
let mut out = String::from("|");
let mut cells = cells.fuse();
for width_of in widths {
let cell = cells.next().unwrap_or_default();
let padding = width_of.saturating_sub(width(&cell));
out.push(' ');
out.push_str(&cell);
out.push_str(&" ".repeat(padding));
out.push_str(" |");
}
out.push('\n');
out
}
fn prompt(text: &str) -> Result<()> {
print!("{text}");
io::stdout().flush().map_err(|e| Error::internal(format!("could not write the prompt: {e}")))
}
fn read_line() -> Result<String> {
let mut line = String::new();
match io::stdin().lock().read_line(&mut line) {
Ok(0) => Err(closed_stdin()),
Ok(_) => Ok(line.trim().to_string()),
Err(e) => Err(Error::internal(format!("could not read the answer: {e}"))),
}
}
fn closed_stdin() -> Error {
Error::internal(
"there is nothing to read from — stdin is closed, so the question cannot be answered \
(a command that runs unattended should take the answer as an argument)",
)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_table_lines_up() {
let rendered = table_to_string(
&["Name", "Queue"],
&[vec!["digest".into(), "default".into()], vec!["backup".into(), "maintenance".into()]],
);
assert_eq!(
rendered,
"\
+--------+-------------+
| Name | Queue |
+--------+-------------+
| digest | default |
| backup | maintenance |
+--------+-------------+
"
);
}
#[test]
fn columns_are_padded_by_character_not_byte() {
let rendered = table_to_string(&["Name"], &[vec!["café".into()], vec!["abcd".into()]]);
let lines: Vec<&str> = rendered.lines().collect();
assert_eq!(lines[3].chars().count(), lines[4].chars().count());
assert_eq!(lines[3], "| café |");
assert_eq!(lines[4], "| abcd |");
}
#[test]
fn a_short_row_is_padded_out() {
let rendered = table_to_string(&["A", "B"], &[vec!["only".into()]]);
assert_eq!(rendered.lines().nth(3).unwrap(), "| only | |");
}
#[test]
fn a_long_row_widens_the_table_rather_than_being_cut() {
let rendered = table_to_string(&["A"], &[vec!["one".into(), "two".into()]]);
assert_eq!(rendered.lines().next().unwrap(), "+-----+-----+");
assert_eq!(rendered.lines().nth(3).unwrap(), "| one | two |");
}
#[test]
fn an_empty_table_is_empty_rather_than_a_lone_rule() {
assert_eq!(table_to_string(&[], &[]), "");
}
#[test]
fn headers_with_no_rows_still_render() {
assert_eq!(
table_to_string(&["Id", "Queue"], &[]),
"\
+----+-------+
| Id | Queue |
+----+-------+
"
);
}
#[test]
fn a_cell_wider_than_its_header_sets_the_width() {
let rendered = table_to_string(&["Id"], &[vec!["01HXQ".into()]]);
assert_eq!(rendered.lines().next().unwrap(), "+-------+");
assert_eq!(rendered.lines().nth(1).unwrap(), "| Id |");
}
}