catfx 0.1.0

Apply effects to a text stream
Documentation
//! CLI utilities.
use crate::error::UnsafeInterval;
use crate::iter::IteratorExt;
use crate::{File, color, util::Colorized};
use clap::Parser;
use regex::Regex;
use std::error::Error;
use std::ffi::OsString;
use std::fmt::Display;
use std::io::{self, BufRead, Write, stdout};
use std::thread::sleep;
use std::time::Duration;
/// Concatenates file contents, and applies optional effects.
#[derive(Debug, Parser)]
#[command(version)]
struct Cli {
    /// The files to concatenate. Use `-` or leave empty for STDIN.
    file: Vec<OsString>,
    /// Lines per second to output.
    #[arg(short = 'l', long = "line-speed")]
    lines_per_second: Option<f32>,
    /// Characters per second to output.
    #[arg(short = 'c', long = "char-speed")]
    chars_per_second: Option<f32>,
    /// Find all matches to a regular expression (Rust syntax) and replace with text.
    #[arg(short = 'r', long = "replace", num_args = 2, value_names = ["REGEXP", "REPLACE"])]
    replace: Vec<String>,
    /// Set the foreground color of the text.
    #[arg(long = "fg", value_name = "NAME_OR_HEX")]
    foreground: Option<color::Color>,
    /// Set the background color of the text.
    #[arg(long = "bg", value_name = "NAME_OR_HEX")]
    background: Option<color::Color>,
}

/// Shortcut to parse arguments and run the CLI.
pub fn run() -> Result<(), Box<dyn Error>> {
    Cli::parse().run()
}

impl Cli {
    /// Runs the CLI.
    #[inline]
    #[must_use = "CLI run result should be handled"]
    pub fn run(mut self) -> Result<(), Box<dyn Error>> {
        if self.file.is_empty() {
            self.file.push("-".into())
        }
        let mut stdout = stdout();

        let replacers = self.replacers()?;

        let line_wait = self
            .lines_per_second
            .map(|per_second| Self::safe_duration(per_second, "lines"))
            .transpose()?
            .unwrap_or(Duration::ZERO);
        let char_wait = self
            .chars_per_second
            .map(|per_second| Self::safe_duration(per_second, "characters"))
            .transpose()?
            .unwrap_or(Duration::ZERO);

        let line_iterators = self
            .file
            .iter()
            .map(File::from)
            .map(|f| f.into_reader())
            .map_ok(|reader| reader.into_buf_reader())
            .map_ok(|reader| reader.lines());

        for lines in line_iterators {
            for line in lines? {
                let line = line.map(|s| Self::find_and_replace(s, &replacers))?;
                if char_wait == Duration::ZERO {
                    // NOTE We can just write the line directly since characters are
                    //      immediate.
                    self.wait_write(&mut stdout, &line, line_wait)?;
                } else {
                    for c in line.chars() {
                        self.wait_write(&mut stdout, c, char_wait)?;
                    }
                }
                // NOTE We lose newlines by iterating per line.
                writeln!(stdout)?;
            }
        }

        if line_wait == Duration::ZERO && char_wait == Duration::ZERO {
            // NOTE We haven't flushed at all, so we'll flush now.
            stdout.flush()?;
        }

        Ok(())
    }

    fn safe_duration(per_second: f32, unit: &'static str) -> Result<Duration, UnsafeInterval<f32>> {
        if per_second <= 0f32 {
            Err(UnsafeInterval::new(unit, per_second))
        } else {
            Ok(Duration::from_secs(1).div_f32(per_second))
        }
    }

    /// Writes the content to the output. If duration is >= 0, it will wait for that
    /// duration before it writes, and flush after it writes, under the assumption that
    /// the wait period is used to animate the output.
    fn wait_write<W, D>(&self, mut out: W, content: D, wait: Duration) -> io::Result<()>
    where
        W: Write,
        D: Display,
    {
        let colorized = Colorized::new(content, &self.foreground, &self.background);
        if wait >= Duration::ZERO {
            sleep(wait);
            write!(out, "{colorized}").and_then(|_| out.flush())
        } else {
            write!(out, "{colorized}")
        }
    }

    fn replacers(&self) -> Result<Vec<FindAndReplace>, Box<dyn Error>> {
        let results = self
            .replace
            .chunks_exact(2)
            .map(|chunk| (Regex::new(&chunk[0]), chunk[1].clone()))
            .map(|(result, string)| result.map(|re| (re, string)))
            .collect::<Result<Vec<_>, _>>()?;
        Ok(results)
    }

    /// Finds and replaces text based on `replace`.
    fn find_and_replace<S>(text: S, replacers: &[FindAndReplace]) -> String
    where
        S: Into<String>,
    {
        replacers.iter().fold(text.into(), |text, (find, replace)| {
            find.replace_all(&text, replace).to_string()
        })
    }
}

/// A pair of a regex to search for and text to replace matches with.
type FindAndReplace = (Regex, String);