#[macro_use]
pub mod fmt;
mod highlighting;
pub mod notification;
pub mod term;
pub use fmt::{get_separator_width, ICON_PADDING, INDENTATION, PADDING};
pub use highlighting::TextHighlighter;
use crate::output::OutputBranding;
use crate::ui::output::OutputFormat;
use crate::{Result, UiError};
use colorful::Colorful;
use indicatif::{ProgressBar, ProgressDrawTarget, ProgressStyle};
use jaq_interpret::{Ctx, FilterT, ParseCtx, RcIter, Val};
use miette::{miette, IntoDiagnostic};
use ockam_core::env::get_env_with_default;
use r3bl_rs_utils_core::{ch, ChUnit};
use r3bl_tuify::{get_size, select_from_list, SelectionMode, StyleSheet};
use serde::Serialize;
use std::fmt::Write as _;
use std::fmt::{Debug, Display};
use std::io::Write;
use std::time::Duration;
use tokio::sync::Mutex;
use tokio::time::sleep;
use tracing::warn;
#[derive(Clone, Debug)]
pub struct Terminal<T: TerminalWriter + Debug, WriteMode = ToStdErr> {
stdout: T,
stderr: T,
logging_options: LoggingOptions,
quiet: bool,
no_input: bool,
output_format: OutputFormat,
mode: WriteMode,
max_width_col_count: usize,
max_height_row_count: usize,
}
impl<T: TerminalWriter + Debug, W> Terminal<T, W> {
pub fn is_quiet(&self) -> bool {
self.quiet
}
fn log_msg(&self, msg: &str) {
if !self.logging_options.enabled {
return;
}
for line in msg.lines() {
let msg = strip_ansi_escapes::strip_str(line);
let msg = msg
.trim()
.trim_start_matches(['✔', '✗', '>', '!'])
.trim_end_matches('\n')
.trim();
if !msg.is_empty() {
info!("{msg}");
}
}
}
pub fn stdout(&self) -> T {
self.stdout.clone()
}
pub fn stderr(&self) -> T {
self.stderr.clone()
}
}
#[derive(Clone, Debug)]
pub struct LoggingOptions {
pub enabled: bool,
pub logging_to_file: bool,
pub with_user_format: bool,
}
#[derive(Clone, Debug)]
pub struct TerminalStream<T: Write + Debug + Clone> {
pub writer: T,
pub no_color: bool,
branding: OutputBranding,
}
impl<T: Write + Debug + Clone> TerminalStream<T> {
pub fn prepare_msg(&self, msg: impl AsRef<str>) -> Result<String> {
let msg = msg.as_ref().to_string();
let msg = self.branding.replace(&msg);
if self.no_color {
Ok(strip_ansi_escapes::strip_str(&msg))
} else {
Ok(msg)
}
}
}
pub trait TerminalWriter: Clone {
fn stdout(no_color: bool, branding: OutputBranding) -> Self;
fn stderr(no_color: bool, branding: OutputBranding) -> Self;
fn is_tty(&self) -> bool;
fn color(&self) -> bool;
fn write(&mut self, s: impl AsRef<str>) -> Result<()>;
fn rewrite(&mut self, s: impl AsRef<str>) -> Result<()>;
fn write_line(&self, s: impl AsRef<str>) -> Result<()>;
}
impl<W: TerminalWriter + Debug> Terminal<W> {
#[allow(clippy::too_many_arguments)]
pub fn new(
logging_options: LoggingOptions,
quiet: bool,
no_color: bool,
no_input: bool,
output_format: OutputFormat,
branding: OutputBranding,
) -> Self {
let no_color = Self::should_disable_color(no_color);
let no_input = Self::should_disable_user_input(no_input);
let stdout = W::stdout(no_color, branding.clone());
let stderr = W::stderr(no_color, branding);
let max_width_col_count = get_size().map(|it| it.col_count).unwrap_or(ch!(80)).into();
Self {
stdout,
stderr,
logging_options,
quiet,
no_input,
output_format,
mode: ToStdErr,
max_width_col_count,
max_height_row_count: 5,
}
}
pub fn confirm(&self, msg: impl AsRef<str>) -> Result<ConfirmResult> {
if !self.can_ask_for_user_input() {
return Ok(ConfirmResult::NonTTY);
}
Ok(ConfirmResult::from(
dialoguer::Confirm::new()
.default(true)
.show_default(true)
.with_prompt(fmt_warn!("{}", msg.as_ref()))
.interact()
.map_err(UiError::Dialoguer)?,
))
}
pub fn confirmed_with_flag_or_prompt(
&self,
flag: bool,
prompt_msg: impl AsRef<str>,
) -> Result<bool> {
if flag {
Ok(true)
} else {
match self.confirm(prompt_msg)? {
ConfirmResult::Yes => Ok(true),
ConfirmResult::No => Ok(false),
ConfirmResult::NonTTY => Err(miette!("Use --yes to confirm"))?,
}
}
}
pub fn confirm_interactively(&self, header: String) -> bool {
let user_input = select_from_list(
header,
["YES", "NO"].iter().map(|it| it.to_string()).collect(),
self.max_height_row_count,
self.max_width_col_count,
SelectionMode::Single,
StyleSheet::default(),
);
match &user_input {
Some(it) => it.contains(&"YES".to_string()),
None => false,
}
}
pub fn select_multiple(&self, header: String, items: Vec<String>) -> Vec<String> {
if !self.can_ask_for_user_input() {
return Vec::new();
}
let user_selected_list = select_from_list(
header,
items,
self.max_height_row_count,
self.max_width_col_count,
SelectionMode::Multiple,
StyleSheet::default(),
);
user_selected_list.unwrap_or_default()
}
pub fn can_ask_for_user_input(&self) -> bool {
!self.no_input && self.stderr.is_tty() && !self.quiet
}
fn should_disable_color(no_color: bool) -> bool {
no_color || get_env_with_default("NO_COLOR", false).unwrap_or(false)
}
fn should_disable_user_input(no_input: bool) -> bool {
no_input || get_env_with_default("NO_INPUT", false).unwrap_or(false)
}
pub fn set_quiet(&self) -> Self {
let mut clone = self.clone();
clone.quiet = true;
clone
}
}
impl<W: TerminalWriter + Debug> Terminal<W, ToStdErr> {
pub fn is_tty(&self) -> bool {
self.stderr.is_tty()
}
fn logging_to_console(&self) -> bool {
self.logging_options.enabled && !self.logging_options.logging_to_file
}
fn can_write_to_stderr(&self) -> bool {
self.logging_options.with_user_format || (!self.logging_to_console() && !self.is_quiet())
}
pub fn write(&self, msg: impl AsRef<str>) -> Result<()> {
self.log_msg(msg.as_ref());
if self.can_write_to_stderr() {
self.stderr.clone().write(msg)?;
}
Ok(())
}
pub fn rewrite(&self, msg: impl AsRef<str>) -> Result<()> {
self.log_msg(msg.as_ref());
if self.can_write_to_stderr() {
self.stderr.clone().rewrite(msg)?;
}
Ok(())
}
pub fn write_line(&self, msg: impl AsRef<str>) -> Result<&Self> {
self.log_msg(msg.as_ref());
if self.can_write_to_stderr() {
self.stderr.write_line(msg)?;
}
Ok(self)
}
pub fn build_list(
&self,
items: &[impl crate::output::Output],
empty_message: &str,
) -> Result<String> {
if items.is_empty() {
return Ok(fmt_info!("{empty_message}"));
}
let mut output = String::new();
for (idx, item) in items.iter().enumerate() {
if idx > 0 {
writeln!(output)?;
}
let item = item.as_list_item()?;
for line in item.lines() {
writeln!(output, "{}", &fmt_list!("{line}"))?;
}
}
Ok(output)
}
pub fn to_stdout(self) -> Terminal<W, ToStdOut> {
Terminal {
stdout: self.stdout,
stderr: self.stderr,
logging_options: self.logging_options,
quiet: self.quiet,
no_input: self.no_input,
output_format: self.output_format,
mode: ToStdOut {
output: Output::new(),
},
max_width_col_count: self.max_width_col_count,
max_height_row_count: self.max_height_row_count,
}
}
}
impl<W: TerminalWriter + Debug> Terminal<W, ToStdOut> {
pub fn is_tty(&self) -> bool {
self.stdout.is_tty()
}
pub fn plain<T: Display>(mut self, msg: T) -> Self {
self.mode.output.plain = Some(msg.to_string());
self
}
pub fn machine<T: Display>(mut self, msg: T) -> Self {
self.mode.output.machine = Some(msg.to_string());
self
}
pub fn json_obj<T: Serialize>(mut self, msg: T) -> Result<Self> {
self.mode.output.json = Some(serde_json::to_value(msg).into_diagnostic()?);
Ok(self)
}
pub fn json<T: Display>(mut self, msg: T) -> Self {
self.mode.output.json = Some(serde_json::from_str(&msg.to_string()).unwrap());
self
}
fn logging_to_console(&self) -> bool {
self.logging_options.enabled && !self.logging_options.logging_to_file
}
fn can_write_to_stdout(&self) -> bool {
self.logging_options.with_user_format || !self.logging_to_console()
}
pub fn write_line(mut self) -> Result<()> {
let msg = match self.mode.output.get_message(
&self.output_format,
self.is_tty(),
self.stdout.color(),
)? {
Some(msg) => msg,
None => return Ok(()),
};
match (&msg, self.mode.output.plain.as_ref()) {
(OutputMessage::Plain(msg), Some(_)) => {
self.log_msg(msg);
}
(_, plain) => {
if let Some(plain) = plain {
self.log_msg(plain);
}
self.log_msg(msg.as_str());
}
}
let msg = msg.as_str().trim_end().trim_end_matches('\n');
if self.can_write_to_stdout() {
if self.stdout.is_tty() {
self.stdout.write_line(msg)?;
} else {
self.stdout.write(msg)?;
}
}
Ok(())
}
}
impl<W: TerminalWriter + Debug> Terminal<W> {
pub fn can_use_progress_bar(&self) -> bool {
self.stderr.is_tty() && self.can_write_to_stderr()
}
pub fn spinner(&self) -> Option<ProgressBar> {
if !self.can_use_progress_bar() {
return None;
}
let ticker = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"]
.into_iter()
.map(|t| format!("{}{t}", ICON_PADDING))
.collect::<Vec<String>>();
let ticker_ref = &ticker.iter().map(|t| t.as_str()).collect::<Vec<&str>>();
let pb = ProgressBar::new_spinner();
pb.set_draw_target(ProgressDrawTarget::stderr());
pb.enable_steady_tick(Duration::from_millis(80));
pb.set_style(
ProgressStyle::with_template("{spinner:.yellow} {msg}")
.expect("Failed to set progress bar template")
.tick_strings(ticker_ref),
);
Some(pb)
}
pub async fn loop_messages(
&self,
output_messages: &[String],
is_finished: &Mutex<bool>,
) -> miette::Result<()> {
if output_messages.is_empty() {
return Ok(());
}
let pb = match self.spinner() {
Some(pb) => pb,
None => return Ok(()),
};
loop {
if *is_finished.lock().await {
break;
}
for message in output_messages {
pb.set_message(message.clone());
sleep(Duration::from_millis(500)).await;
if *is_finished.lock().await {
break;
}
}
}
pb.finish_and_clear();
Ok(())
}
}
#[derive(Clone, Debug)]
pub struct ToStdErr;
#[derive(Clone, Debug)]
pub struct ToStdOut {
pub(self) output: Output,
}
#[derive(Clone, Debug)]
struct Output {
plain: Option<String>,
machine: Option<String>,
json: Option<serde_json::Value>,
}
impl Output {
fn new() -> Self {
Self {
plain: None,
machine: None,
json: None,
}
}
fn get_message(
&self,
format: &OutputFormat,
is_tty: bool,
color: bool,
) -> Result<Option<OutputMessage>> {
if self.plain.is_none() && self.machine.is_none() && self.json.is_none() {
return Err(miette!("At least one output format must be defined"))?;
}
let plain = self.plain.as_ref();
let machine = self.machine.as_ref();
let json = self.json.as_ref();
let (jq_query, compact) = match format.clone() {
OutputFormat::Json { jq_query, compact } => (jq_query, compact),
_ => (None, false),
};
let msg =
match format {
OutputFormat::Plain => {
if is_tty {
match (plain, machine, json) {
(Some(plain), _, _) => OutputMessage::Plain(plain.clone()),
(None, Some(machine), _) => OutputMessage::Machine(machine.clone()),
(None, None, Some(json)) => OutputMessage::Json(
self.process_json_output(json, jq_query.as_ref(), compact, color)?,
),
_ => unreachable!(),
}
}
else {
match (machine, json, plain) {
(Some(machine), _, _) => OutputMessage::Machine(machine.clone()),
(None, Some(json), _) => OutputMessage::Json(
self.process_json_output(json, jq_query.as_ref(), compact, color)?,
),
(None, None, Some(plain)) => OutputMessage::Plain(plain.clone()),
_ => unreachable!(),
}
}
}
OutputFormat::Json { .. } => match json {
Some(json) => OutputMessage::Json(self.process_json_output(
json,
jq_query.as_ref(),
compact,
color,
)?),
None => {
warn!("JSON output is not defined for this command");
return Ok(None);
}
},
};
Ok(Some(msg))
}
fn process_json_output(
&self,
json: &serde_json::Value,
jq_query: Option<&String>,
compact: bool,
color: bool,
) -> Result<String> {
let json_string = match jq_query {
None => self.json_to_string(json, compact)?,
Some(jq_query) => {
let filter = {
let mut ctx = ParseCtx::new(Vec::new());
ctx.insert_natives(jaq_core::core());
ctx.insert_defs(jaq_std::std());
let (filter, errs) = jaq_parse::parse(jq_query, jaq_parse::main());
if !errs.is_empty() {
let error_message = errs
.iter()
.map(|e| e.to_string())
.collect::<Vec<_>>()
.join(", ");
return Err(miette!(error_message))?;
}
match filter {
Some(filter) => ctx.compile(filter),
None => return Err(miette!("Failed to parse jq query"))?,
}
};
let jq_inputs = RcIter::new(core::iter::empty());
let mut jq_output = filter.run((Ctx::new([], &jq_inputs), Val::from(json.clone())));
let mut ret = Vec::<serde_json::Value>::new();
while let Some(Ok(val)) = jq_output.next() {
ret.push(val.into());
}
let as_string: Vec<String> = ret
.iter()
.map(|item| self.json_to_string(item, compact))
.collect::<Result<Vec<String>>>()?;
as_string.join("\n")
}
};
let highlighted_json = if color {
let highlighter = TextHighlighter::new("json")?;
highlighter.process(&json_string)?
} else {
json_string
};
Ok(highlighted_json)
}
fn json_to_string<T>(&self, json: &T, compact: bool) -> Result<String>
where
T: ?Sized + Serialize,
{
Ok(if compact {
serde_json::to_string(&json).into_diagnostic()?
} else {
serde_json::to_string_pretty(&json).into_diagnostic()?
})
}
}
#[derive(Clone, Debug, PartialEq)]
enum OutputMessage {
Plain(String),
Machine(String),
Json(String),
}
impl OutputMessage {
fn as_str(&self) -> &str {
match self {
OutputMessage::Plain(msg) => msg,
OutputMessage::Machine(msg) => msg,
OutputMessage::Json(msg) => msg,
}
}
}
pub enum ConfirmResult {
Yes,
No,
NonTTY,
}
impl From<bool> for ConfirmResult {
fn from(value: bool) -> Self {
if value {
ConfirmResult::Yes
} else {
ConfirmResult::No
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use ockam_core::compat::rand::random_string;
#[test]
fn output_invalid_cases() {
let msg = Output::new().get_message(&OutputFormat::Plain, true, false);
assert!(msg.is_err());
let output = Output {
plain: Some("plain".to_string()),
machine: None,
json: None,
};
let msg = output
.get_message(
&OutputFormat::Json {
jq_query: None,
compact: false,
},
true,
false,
)
.unwrap();
assert!(msg.is_none());
}
#[test]
fn output_to_output_message() {
let plain = "plain".to_string();
let machine = "machine".to_string();
let json = serde_json::json!({ "key": "value" });
let output = Output {
plain: Some(plain.clone()),
machine: Some(machine.clone()),
json: Some(json.clone()),
};
let msg = output
.get_message(&OutputFormat::Plain, true, false)
.unwrap()
.unwrap();
assert_eq!(msg, OutputMessage::Plain(plain));
let msg = output
.get_message(&OutputFormat::Plain, false, false)
.unwrap()
.unwrap();
assert_eq!(msg, OutputMessage::Machine(machine));
let format = OutputFormat::Json {
jq_query: None,
compact: false,
};
let msg = output.get_message(&format, true, false).unwrap().unwrap();
assert_eq!(
msg,
OutputMessage::Json(serde_json::to_string_pretty(&json).unwrap())
);
let msg = output.get_message(&format, false, false).unwrap().unwrap();
assert_eq!(
msg,
OutputMessage::Json(serde_json::to_string_pretty(&json).unwrap())
);
}
#[test]
fn output_to_output_message_plain_fallbacks() {
let msg = random_string();
let json = serde_json::json!({ "key": "value" });
let output = Output {
plain: None,
machine: Some(msg.clone()),
json: Some(json.clone()),
};
assert_eq!(
output
.get_message(&OutputFormat::Plain, true, false)
.unwrap()
.unwrap(),
OutputMessage::Machine(msg.clone())
);
let output = Output {
plain: None,
machine: None,
json: Some(json.clone()),
};
assert_eq!(
output
.get_message(&OutputFormat::Plain, true, false)
.unwrap()
.unwrap(),
OutputMessage::Json(serde_json::to_string_pretty(&json).unwrap())
);
let output = Output {
plain: Some(msg.clone()),
machine: None,
json: Some(json.clone()),
};
assert_eq!(
output
.get_message(&OutputFormat::Plain, false, false)
.unwrap()
.unwrap(),
OutputMessage::Json(serde_json::to_string_pretty(&json).unwrap())
);
let output = Output {
plain: Some(msg.clone()),
machine: None,
json: None,
};
assert_eq!(
output
.get_message(&OutputFormat::Plain, false, false)
.unwrap()
.unwrap(),
OutputMessage::Plain(msg.clone())
);
}
#[test]
fn output_message_json_formatting() {
let json = serde_json::json!({ "key": "value" });
let output = Output {
plain: None,
machine: None,
json: Some(json.clone()),
};
assert_eq!(
output
.get_message(
&OutputFormat::Json {
jq_query: None,
compact: false
},
true,
false
)
.unwrap()
.unwrap(),
OutputMessage::Json(serde_json::to_string_pretty(&json).unwrap())
);
let output_message = output
.get_message(
&OutputFormat::Json {
jq_query: None,
compact: false,
},
true,
true,
)
.unwrap()
.unwrap();
assert!(output_message.as_str().contains('\u{1b}'));
assert!(output_message.as_str().contains('\n'));
assert_eq!(
output
.get_message(
&OutputFormat::Json {
jq_query: None,
compact: true
},
true,
false
)
.unwrap()
.unwrap(),
OutputMessage::Json(serde_json::to_string(&json).unwrap())
);
let output_message = output
.get_message(
&OutputFormat::Json {
jq_query: None,
compact: true,
},
true,
true,
)
.unwrap()
.unwrap();
assert!(output_message.as_str().contains('\u{1b}'));
assert!(!output_message.as_str().contains('\n'));
}
}