all lists on lists.proxmox.com
 help / color / mirror / Atom feed
From: Lukas Wagner <l.wagner@proxmox.com>
To: pbs-devel@lists.proxmox.com
Subject: [pbs-devel] [PATCH v3 proxmox-backup 3/4] debug cli: add colored output for `diff archive`
Date: Wed,  7 Dec 2022 10:38:18 +0100	[thread overview]
Message-ID: <20221207093819.75847-4-l.wagner@proxmox.com> (raw)
In-Reply-To: <20221207093819.75847-1-l.wagner@proxmox.com>

This commit adds the `--color` flag to the `diff archive` tool.
Valid values are `always`, `auto` and `never`. `always` and
`never` should be self-explanatory, whereas `auto` will enable
colors unless one of the following is true:
  - STDOUT is not a tty
  - TERM=dumb is set
  - NO_COLOR is set

The tool will highlight changed file attributes in yellow.
Furthermore, (A)dded files are highlighted in green,
(M)odified in yellow and (D)eleted in red.

Signed-off-by: Lukas Wagner <l.wagner@proxmox.com>
---

Changes from v2:
  - Drop dependency on `atty` crate
  - Remove modificiations to debian/control

 Cargo.toml                           |   1 +
 src/bin/proxmox_backup_debug/diff.rs | 371 ++++++++++++++++++++++-----
 2 files changed, 306 insertions(+), 66 deletions(-)

diff --git a/Cargo.toml b/Cargo.toml
index 8ee48127..7fac1bfa 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -75,6 +75,7 @@ serde = { version = "1.0", features = ["derive"] }
 serde_json = "1.0"
 siphasher = "0.3"
 syslog = "4.0"
+termcolor = "1.1.2"
 tokio = { version = "1.6", features = [ "fs", "io-util", "io-std", "macros", "net", "parking_lot", "process", "rt", "rt-multi-thread", "signal", "time" ] }
 tokio-openssl = "0.6.1"
 tokio-stream = "0.1.0"
diff --git a/src/bin/proxmox_backup_debug/diff.rs b/src/bin/proxmox_backup_debug/diff.rs
index a8a4e08d..2149720f 100644
--- a/src/bin/proxmox_backup_debug/diff.rs
+++ b/src/bin/proxmox_backup_debug/diff.rs
@@ -1,5 +1,6 @@
 use std::collections::{HashMap, HashSet};
 use std::ffi::{OsStr, OsString};
+use std::io::Write;
 use std::iter::FromIterator;
 use std::path::{Path, PathBuf};
 use std::sync::Arc;
@@ -28,6 +29,8 @@ use pbs_tools::json::required_string_param;
 use pxar::accessor::ReadAt;
 use pxar::EntryKind;
 use serde_json::Value;
+
+use termcolor::{Color, ColorChoice, ColorSpec, StandardStream, WriteColor};
 use tokio::io::AsyncReadExt;
 
 type ChunkDigest = [u8; 32];
@@ -87,6 +90,11 @@ pub fn diff_commands() -> CommandLineInterface {
                 type: bool,
                 description: "Compare file content rather than solely relying on mtime for detecting modified files.",
             },
+            "color": {
+                optional: true,
+                type: String,
+                description: "Set mode for colored output. Can be `always`, `auto` or `never`. `auto` will display colors only if stdout is a tty. Defaults to `auto`."
+            }
         }
     }
 )]
@@ -106,6 +114,12 @@ async fn diff_archive_cmd(param: Value) -> Result<(), Error> {
         None => false,
     };
 
+    let color = match param.get("color") {
+        Some(Value::String(color)) => color.as_str().try_into()?,
+        Some(_) => bail!("invalid color parameter. Valid choices are `always`, `auto` and `never`"),
+        None => ColorMode::Auto,
+    };
+
     let namespace = match param.get("ns") {
         Some(Value::String(ns)) => ns.parse()?,
         Some(_) => bail!("invalid namespace parameter"),
@@ -133,6 +147,8 @@ async fn diff_archive_cmd(param: Value) -> Result<(), Error> {
         namespace,
     };
 
+    let output_params = OutputParams { color };
+
     if archive_name.ends_with(".pxar") {
         let file_name = format!("{}.didx", archive_name);
         diff_archive(
@@ -141,6 +157,7 @@ async fn diff_archive_cmd(param: Value) -> Result<(), Error> {
             &file_name,
             &repo_params,
             compare_contents,
+            &output_params,
         )
         .await?;
     } else {
@@ -156,6 +173,7 @@ async fn diff_archive(
     file_name: &str,
     repo_params: &RepoParams,
     compare_contents: bool,
+    output_params: &OutputParams,
 ) -> Result<(), Error> {
     let (index_a, accessor_a) = open_dynamic_index(snapshot_a, file_name, repo_params).await?;
     let (index_b, accessor_b) = open_dynamic_index(snapshot_b, file_name, repo_params).await?;
@@ -209,17 +227,40 @@ async fn diff_archive(
     // which where *really* modified.
     let modified_files = compare_files(potentially_modified, compare_contents).await?;
 
-    show_file_list(&added_files, &deleted_files, &modified_files);
+    show_file_list(&added_files, &deleted_files, &modified_files, output_params)?;
 
     Ok(())
 }
 
+enum ColorMode {
+    Always,
+    Auto,
+    Never,
+}
+
+impl TryFrom<&str> for ColorMode {
+    type Error = Error;
+
+    fn try_from(value: &str) -> Result<Self, Self::Error> {
+        match value {
+            "auto" => Ok(Self::Auto),
+            "always" => Ok(Self::Always),
+            "never" => Ok(Self::Never),
+            _ => bail!("invalid color parameter. Valid choices are `always`, `auto` and `never`"),
+        }
+    }
+}
+
 struct RepoParams {
     repo: BackupRepository,
     crypt_config: Option<Arc<CryptConfig>>,
     namespace: BackupNamespace,
 }
 
+struct OutputParams {
+    color: ColorMode,
+}
+
 async fn open_dynamic_index(
     snapshot: &str,
     archive_name: &str,
@@ -531,70 +572,271 @@ impl ChangedProperties {
     }
 }
 
-fn change_indicator(changed: bool) -> &'static str {
-    if changed {
-        "*"
-    } else {
-        " "
-    }
+enum FileOperation {
+    Added,
+    Modified,
+    Deleted,
 }
 
-fn format_filesize(entry: &FileEntry, changed: bool) -> String {
-    if let Some(size) = entry.file_size() {
-        format!(
-            "{}{:.1}",
-            change_indicator(changed),
-            HumanByte::new_decimal(size as f64)
-        )
-    } else {
-        String::new()
+struct ColumnWidths {
+    operation: usize,
+    entry_type: usize,
+    uid: usize,
+    gid: usize,
+    mode: usize,
+    filesize: usize,
+    mtime: usize,
+}
+
+impl Default for ColumnWidths {
+    fn default() -> Self {
+        Self {
+            operation: 1,
+            entry_type: 2,
+            uid: 6,
+            gid: 6,
+            mode: 6,
+            filesize: 10,
+            mtime: 11,
+        }
     }
 }
 
-fn format_mtime(entry: &FileEntry, changed: bool) -> String {
-    let mtime = &entry.metadata().stat.mtime;
+struct FileEntryPrinter {
+    stream: StandardStream,
+    column_widths: ColumnWidths,
+    changed_color: Color,
+}
 
-    let mut format = change_indicator(changed).to_owned();
-    format.push_str("%F %T");
+impl FileEntryPrinter {
+    pub fn new(output_params: &OutputParams) -> Self {
+        let color_choice = match output_params.color {
+            ColorMode::Always => ColorChoice::Always,
+            ColorMode::Auto => {
+                if unsafe { libc::isatty(1) == 1 } {
+                    // Show colors unless `TERM=dumb` or `NO_COLOR` is set.
+                    ColorChoice::Auto
+                } else {
+                    ColorChoice::Never
+                }
+            }
+            ColorMode::Never => ColorChoice::Never,
+        };
 
-    proxmox_time::strftime_local(&format, mtime.secs).unwrap_or_default()
-}
+        let stdout = StandardStream::stdout(color_choice);
 
-fn format_mode(entry: &FileEntry, changed: bool) -> String {
-    let mode = entry.metadata().stat.mode & 0o7777;
-    format!("{}{:o}", change_indicator(changed), mode)
-}
+        Self {
+            stream: stdout,
+            column_widths: ColumnWidths::default(),
+            changed_color: Color::Yellow,
+        }
+    }
 
-fn format_entry_type(entry: &FileEntry, changed: bool) -> String {
-    let kind = match entry.kind() {
-        EntryKind::Symlink(_) => "l",
-        EntryKind::Hardlink(_) => "h",
-        EntryKind::Device(_) if entry.metadata().stat.is_blockdev() => "b",
-        EntryKind::Device(_) => "c",
-        EntryKind::Socket => "s",
-        EntryKind::Fifo => "p",
-        EntryKind::File { .. } => "f",
-        EntryKind::Directory => "d",
-        _ => " ",
-    };
+    fn change_indicator(&self, changed: bool) -> &'static str {
+        if changed {
+            "*"
+        } else {
+            " "
+        }
+    }
 
-    format!("{}{}", change_indicator(changed), kind)
-}
+    fn set_color_if_changed(&mut self, changed: bool) -> Result<(), Error> {
+        if changed {
+            self.stream
+                .set_color(ColorSpec::new().set_fg(Some(self.changed_color)))?;
+        }
 
-fn format_uid(entry: &FileEntry, changed: bool) -> String {
-    format!("{}{}", change_indicator(changed), entry.metadata().stat.uid)
-}
+        Ok(())
+    }
 
-fn format_gid(entry: &FileEntry, changed: bool) -> String {
-    format!("{}{}", change_indicator(changed), entry.metadata().stat.gid)
-}
+    fn write_operation(&mut self, op: FileOperation) -> Result<(), Error> {
+        let (text, color) = match op {
+            FileOperation::Added => ("A", Color::Green),
+            FileOperation::Modified => ("M", Color::Yellow),
+            FileOperation::Deleted => ("D", Color::Red),
+        };
 
-fn format_file_name(entry: &FileEntry, changed: bool) -> String {
-    format!(
-        "{}{}",
-        change_indicator(changed),
-        entry.file_name().to_string_lossy()
-    )
+        self.stream
+            .set_color(ColorSpec::new().set_fg(Some(color)))?;
+
+        write!(
+            self.stream,
+            "{text:>width$}",
+            width = self.column_widths.operation,
+        )?;
+
+        self.stream.reset()?;
+
+        Ok(())
+    }
+
+    fn write_filesize(&mut self, entry: &FileEntry, changed: bool) -> Result<(), Error> {
+        let output = if let Some(size) = entry.file_size() {
+            format!(
+                "{}{:.1}",
+                self.change_indicator(changed),
+                HumanByte::new_decimal(size as f64)
+            )
+        } else {
+            String::new()
+        };
+
+        self.set_color_if_changed(changed)?;
+        write!(
+            self.stream,
+            "{output:>width$}",
+            width = self.column_widths.filesize,
+        )?;
+        self.stream.reset()?;
+
+        Ok(())
+    }
+
+    fn write_mtime(&mut self, entry: &FileEntry, changed: bool) -> Result<(), Error> {
+        let mtime = &entry.metadata().stat.mtime;
+
+        let mut format = self.change_indicator(changed).to_owned();
+        format.push_str("%F %T");
+
+        let output = proxmox_time::strftime_local(&format, mtime.secs).unwrap_or_default();
+
+        self.set_color_if_changed(changed)?;
+        write!(
+            self.stream,
+            "{output:>width$}",
+            width = self.column_widths.mtime,
+        )?;
+        self.stream.reset()?;
+
+        Ok(())
+    }
+
+    fn write_mode(&mut self, entry: &FileEntry, changed: bool) -> Result<(), Error> {
+        let mode = entry.metadata().stat.mode & 0o7777;
+        let output = format!("{}{:o}", self.change_indicator(changed), mode);
+
+        self.set_color_if_changed(changed)?;
+        write!(
+            self.stream,
+            "{output:>width$}",
+            width = self.column_widths.mode,
+        )?;
+        self.stream.reset()?;
+
+        Ok(())
+    }
+
+    fn write_entry_type(&mut self, entry: &FileEntry, changed: bool) -> Result<(), Error> {
+        let kind = match entry.kind() {
+            EntryKind::Symlink(_) => "l",
+            EntryKind::Hardlink(_) => "h",
+            EntryKind::Device(_) if entry.metadata().stat.is_blockdev() => "b",
+            EntryKind::Device(_) => "c",
+            EntryKind::Socket => "s",
+            EntryKind::Fifo => "p",
+            EntryKind::File { .. } => "f",
+            EntryKind::Directory => "d",
+            _ => " ",
+        };
+
+        let output = format!("{}{}", self.change_indicator(changed), kind);
+
+        self.set_color_if_changed(changed)?;
+        write!(
+            self.stream,
+            "{output:>width$}",
+            width = self.column_widths.entry_type,
+        )?;
+        self.stream.reset()?;
+
+        Ok(())
+    }
+
+    fn write_uid(&mut self, entry: &FileEntry, changed: bool) -> Result<(), Error> {
+        let output = format!(
+            "{}{}",
+            self.change_indicator(changed),
+            entry.metadata().stat.uid
+        );
+
+        self.set_color_if_changed(changed)?;
+        write!(
+            self.stream,
+            "{output:>width$}",
+            width = self.column_widths.uid,
+        )?;
+        self.stream.reset()?;
+        Ok(())
+    }
+
+    fn write_gid(&mut self, entry: &FileEntry, changed: bool) -> Result<(), Error> {
+        let output = format!(
+            "{}{}",
+            self.change_indicator(changed),
+            entry.metadata().stat.gid
+        );
+
+        self.set_color_if_changed(changed)?;
+        write!(
+            self.stream,
+            "{output:>width$}",
+            width = self.column_widths.gid,
+        )?;
+        self.stream.reset()?;
+        Ok(())
+    }
+
+    fn write_file_name(&mut self, entry: &FileEntry, changed: bool) -> Result<(), Error> {
+        self.set_color_if_changed(changed)?;
+        write!(
+            self.stream,
+            "{}{}",
+            self.change_indicator(changed),
+            entry.file_name().to_string_lossy()
+        )?;
+        self.stream.reset()?;
+
+        Ok(())
+    }
+
+    fn write_column_seperator(&mut self) -> Result<(), Error> {
+        write!(self.stream, " ")?;
+        Ok(())
+    }
+
+    /// Print a file entry, including `changed` indicators and column seperators
+    pub fn print_file_entry(
+        &mut self,
+        entry: &FileEntry,
+        changed: &ChangedProperties,
+        operation: FileOperation,
+    ) -> Result<(), Error> {
+        self.write_operation(operation)?;
+        self.write_column_seperator()?;
+
+        self.write_entry_type(entry, changed.entry_type)?;
+        self.write_column_seperator()?;
+
+        self.write_uid(entry, changed.uid)?;
+        self.write_column_seperator()?;
+
+        self.write_gid(entry, changed.gid)?;
+        self.write_column_seperator()?;
+
+        self.write_mode(entry, changed.mode)?;
+        self.write_column_seperator()?;
+
+        self.write_filesize(entry, changed.size)?;
+        self.write_column_seperator()?;
+
+        self.write_mtime(entry, changed.mtime)?;
+        self.write_column_seperator()?;
+
+        self.write_file_name(entry, changed.content)?;
+        writeln!(self.stream)?;
+
+        Ok(())
+    }
 }
 
 /// Display a sorted list of added, modified, deleted files.
@@ -602,7 +844,8 @@ fn show_file_list(
     added: &HashMap<&OsStr, &FileEntry>,
     deleted: &HashMap<&OsStr, &FileEntry>,
     modified: &HashMap<&OsStr, (&FileEntry, ChangedProperties)>,
-) {
+    output_params: &OutputParams,
+) -> Result<(), Error> {
     let mut all: Vec<&OsStr> = Vec::new();
 
     all.extend(added.keys());
@@ -611,27 +854,23 @@ fn show_file_list(
 
     all.sort();
 
+    let mut printer = FileEntryPrinter::new(output_params);
+
     for file in all {
-        let (op, entry, changed) = if let Some(entry) = added.get(file) {
-            ("A", entry, ChangedProperties::default())
+        let (operation, entry, changed) = if let Some(entry) = added.get(file) {
+            (FileOperation::Added, entry, ChangedProperties::default())
         } else if let Some(entry) = deleted.get(file) {
-            ("D", entry, ChangedProperties::default())
+            (FileOperation::Deleted, entry, ChangedProperties::default())
         } else if let Some((entry, changed)) = modified.get(file) {
-            ("M", entry, *changed)
+            (FileOperation::Modified, entry, *changed)
         } else {
             unreachable!();
         };
 
-        let entry_type = format_entry_type(entry, changed.entry_type);
-        let uid = format_uid(entry, changed.uid);
-        let gid = format_gid(entry, changed.gid);
-        let mode = format_mode(entry, changed.mode);
-        let size = format_filesize(entry, changed.size);
-        let mtime = format_mtime(entry, changed.mtime);
-        let name = format_file_name(entry, changed.content);
-
-        println!("{op} {entry_type:>2} {mode:>5} {uid:>6} {gid:>6} {size:>10} {mtime:11} {name}");
+        printer.print_file_entry(entry, &changed, operation)?;
     }
+
+    Ok(())
 }
 
 #[cfg(test)]
-- 
2.30.2





  parent reply	other threads:[~2022-12-07  9:39 UTC|newest]

Thread overview: 8+ messages / expand[flat|nested]  mbox.gz  Atom feed  top
2022-12-07  9:38 [pbs-devel] [PATCH v3 proxmox-backup 0/4] debug cli: improve output, optionally compare file content " Lukas Wagner
2022-12-07  9:38 ` [pbs-devel] [PATCH v3 proxmox-backup 1/4] debug cli: show more file attributes for `diff archive` command Lukas Wagner
2022-12-09  9:13   ` Wolfgang Bumiller
2022-12-09 10:45     ` Lukas Wagner
2022-12-07  9:38 ` [pbs-devel] [PATCH v3 proxmox-backup 2/4] debug cli: add 'compare-content' flag to " Lukas Wagner
2022-12-07  9:38 ` Lukas Wagner [this message]
2022-12-07  9:38 ` [pbs-devel] [PATCH v3 proxmox-backup 4/4] debug cli: move parameters into the function signature Lukas Wagner
2022-12-09  9:22   ` Wolfgang Bumiller

Reply instructions:

You may reply publicly to this message via plain-text email
using any one of the following methods:

* Save the following mbox file, import it into your mail client,
  and reply-to-all from there: mbox

  Avoid top-posting and favor interleaved quoting:
  https://en.wikipedia.org/wiki/Posting_style#Interleaved_style

* Reply using the --to, --cc, and --in-reply-to
  switches of git-send-email(1):

  git send-email \
    --in-reply-to=20221207093819.75847-4-l.wagner@proxmox.com \
    --to=l.wagner@proxmox.com \
    --cc=pbs-devel@lists.proxmox.com \
    /path/to/YOUR_REPLY

  https://kernel.org/pub/software/scm/git/docs/git-send-email.html

* If your mail client supports setting the In-Reply-To header
  via mailto: links, try the mailto: link
Be sure your reply has a Subject: header at the top and a blank line before the message body.
This is an external index of several public inboxes,
see mirroring instructions on how to clone and mirror
all data and code used by this external index.
Service provided by Proxmox Server Solutions GmbH | Privacy | Legal