diff --git a/.envrc b/.envrc deleted file mode 100644 index 3550a30..0000000 --- a/.envrc +++ /dev/null @@ -1 +0,0 @@ -use flake diff --git a/.gitignore b/.gitignore index c0dc59d..4592497 100644 --- a/.gitignore +++ b/.gitignore @@ -1,7 +1,11 @@ +# build /target /result -.direnv +# environments .env* !.env.example +# direnv +.envrc +.direnv diff --git a/Cargo.lock b/Cargo.lock index 98951b4..a0dec75 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -196,7 +196,9 @@ checksum = "49874b5167b65d7193b8aba1567f5c7d93d001cafc34600cee003eda787e483f" name = "vnetod" version = "0.3.3" dependencies = [ + "atty", "clap", + "termcolor", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index fc6b11e..046604e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -13,6 +13,8 @@ categories = ["command-line-interface", "config", "development-tools"] [dependencies] clap = { version = "3.2.15", default-features = false, features = ["std", "env", "derive"] } +atty = { version = "0.2.14", optional = true } +termcolor = { version = "1.1.3", optional = true } [features] -color = ["clap/color"] +color = ["clap/color", "dep:atty", "dep:termcolor"] diff --git a/flake.nix b/flake.nix index 4ae4d21..2a52409 100644 --- a/flake.nix +++ b/flake.nix @@ -11,22 +11,28 @@ cargoToml = fromTOML (readFile ./Cargo.toml); version = "${cargoToml.package.version}+${substring 0 8 self.lastModifiedDate}.${self.shortRev or "dirty"}"; - mkVnetod = { lib, rustPlatform, ... }: + mkVnetod = { lib, rustPlatform, vnetodFeatures ? [ ], ... }: rustPlatform.buildRustPackage { name = "vnetod-${version}"; src = lib.cleanSource ./.; cargoLock.lockFile = ./Cargo.lock; + buildFeatures = vnetodFeatures; + doCheck = true; }; in { - overlays = rec { - vnetod = final: prev: { + overlays = { + default = final: prev: { vnetod = final.callPackage mkVnetod { }; }; - default = vnetod; + colored = final: prev: { + vnetod = final.callPackage mkVnetod { + vnetodFeatures = [ "color" ]; + }; + }; }; } // flake-utils.lib.eachDefaultSystem (system: @@ -34,6 +40,7 @@ pkgs = import nixpkgs { inherit system; }; vnetod = pkgs.callPackage mkVnetod { }; + coloredVnetod = pkgs.callPackage mkVnetod { vnetodFeatures = [ "color" ]; }; docker = pkgs.dockerTools.buildLayeredImage { name = "pleshevskiy/vnetod"; @@ -41,19 +48,25 @@ config = { Volumes."/data" = { }; WorkingDir = "/data"; - Entrypoint = [ "${vnetod}/bin/vnetod" ]; + Entrypoint = [ "${coloredVnetod}/bin/vnetod" ]; }; }; + + mkApp = prog: { + type = "app"; + program = "${vnetod}/bin/vnetod"; + }; in { - apps.default = { - type = "app"; - program = "${vnetod}/bin/vnetod"; + apps = { + default = mkApp vnetod; + colored = mkApp coloredVnetod; }; packages = { inherit docker vnetod; default = vnetod; + colored = coloredVnetod; }; devShell = pkgs.mkShell { diff --git a/src/cli.rs b/src/cli.rs index e0d0b2a..ba2bf49 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -20,7 +20,7 @@ use std::path::PathBuf; use clap::Parser; -#[derive(Parser)] +#[derive(Parser, Debug)] #[clap( author, version, @@ -62,4 +62,63 @@ pub struct Args { help = "Environment varible sections that will be enabled" )] pub sections: Vec, + + #[cfg(feature = "color")] + #[clap( + long, + value_enum, + default_value = "auto", + long_help = " +This flag controls when to use colors. The default setting is 'auto', which +means vnetod will try to guess when to use colors. For example, if vnetod is +printing to a terminal, then it will use colors, but if it is redirected to a +file or a pipe, then it will suppress color output. vnetod will suppress color +output in some other circumstances as well. For example, if the TERM +environment variable is not set or set to 'dumb', then vnetod will not use +colors. + +The possible values for this flag are: + +never Colors will never be used. +auto The default. vnetod tries to be smart. +always Colors will always be used regardless of where output is sent. +ansi Like 'always', but emits ANSI escapes (even in a Windows console). + +" + )] + pub color: ColorVariant, +} + +#[cfg(feature = "color")] +#[derive(clap::ValueEnum, Clone, Debug)] +pub enum ColorVariant { + Auto, + Always, + Ansi, + Never, +} + +#[cfg(feature = "color")] +impl From for termcolor::ColorChoice { + fn from(col: ColorVariant) -> Self { + match col { + ColorVariant::Never => Self::Never, + ColorVariant::Always => Self::Always, + ColorVariant::Ansi => Self::AlwaysAnsi, + ColorVariant::Auto => { + if atty::is(atty::Stream::Stdout) { + // Otherwise let's `termcolor` decide by inspecting the environment. From the [doc]: + // - If `NO_COLOR` is set to any value, then colors will be suppressed. + // - If `TERM` is set to dumb, then colors will be suppressed. + // - In non-Windows environments, if `TERM` is not set, then colors will be suppressed. + // + // [doc]: https://github.com/BurntSushi/termcolor#automatic-color-selection + Self::Auto + } else { + // Colors should be deactivated if the terminal is not a tty. + Self::Never + } + } + } + } } diff --git a/src/cli/switch.rs b/src/cli/switch.rs index 1f4c514..dcfd6d6 100644 --- a/src/cli/switch.rs +++ b/src/cli/switch.rs @@ -14,21 +14,24 @@ //! along with vnetod. If not, see . //! +#[cfg(feature = "color")] +use termcolor::{Color, ColorSpec, StandardStream, WriteColor}; + use crate::{cli::Args, domain}; use std::fs::File; -use std::io::{stdout, Write}; +use std::io::Write; #[derive(Debug)] pub enum Error { OpenFile, - Switch(domain::switch::Error), + WriteFile, } impl std::fmt::Display for Error { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self { Error::OpenFile => f.write_str("Cannot open file"), - Error::Switch(inner) => write!(f, "Cannot switch between states: {}", inner), + Error::WriteFile => f.write_str("Cannot write file"), } } } @@ -38,19 +41,51 @@ impl std::error::Error for Error {} pub fn execute(args: &Args) -> Result<(), Error> { let content = std::fs::read_to_string(&args.file).map_err(|_| Error::OpenFile)?; - let writer: Box = if args.dry_run { - Box::new(stdout()) - } else { - Box::new( - File::create(args.output.as_ref().unwrap_or(&args.file)) - .map_err(|_| Error::OpenFile)?, - ) - }; + if args.dry_run { + println!("Your file will be changed to the following") + } - domain::switch::execute(domain::switch::Request { - content: &content, - writer, + let fs_writer = (!args.dry_run) + .then(|| { + File::create(args.output.as_ref().unwrap_or(&args.file)).map_err(|_| Error::OpenFile) + }) + .transpose()?; + + #[cfg(feature = "color")] + let color = args.color.clone(); + + println!(); + + let new_content = domain::switch::execute(domain::switch::Request { + content: &content.trim(), sections: &args.sections, - }) - .map_err(Error::Switch) + on_line: Some(Box::new(move |line| { + #[cfg(feature = "color")] + print_line(line, color.clone()); + #[cfg(not(feature = "color"))] + print!("{}", line) + })), + }); + + println!(); + + if let Some(mut fs_writer) = fs_writer { + fs_writer + .write_all(new_content.as_bytes()) + .map_err(|_| Error::WriteFile)?; + } + + Ok(()) +} + +#[cfg(feature = "color")] +fn print_line(line: &String, color: crate::cli::ColorVariant) { + let mut stdout = StandardStream::stdout(color.into()); + let color = line + .starts_with("###") + .then_some(Color::Yellow) + .or_else(|| (!line.starts_with("#")).then_some(Color::Green)); + stdout.set_color(ColorSpec::new().set_fg(color)).ok(); + write!(&mut stdout, "{}", line).unwrap(); + stdout.reset().ok(); } diff --git a/src/domain.rs b/src/domain.rs index d35672a..960684f 100644 --- a/src/domain.rs +++ b/src/domain.rs @@ -44,7 +44,7 @@ impl Section { } } -struct SectionInfo { +pub struct SectionInfo { enable_variable: bool, disable_variable: bool, } diff --git a/src/domain/switch.rs b/src/domain/switch.rs index ecf32bf..01882e7 100644 --- a/src/domain/switch.rs +++ b/src/domain/switch.rs @@ -14,54 +14,32 @@ //! along with vnetod. If not, see . //! -use std::io::{BufWriter, Write}; - use super::{Section, SectionInfo}; -#[derive(Debug)] -pub enum Error { - WriteData, -} +pub type OnLineFn = Box; -impl std::fmt::Display for Error { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - match self { - Error::WriteData => f.write_str("Cannot write data"), - } - } -} - -impl std::error::Error for Error {} - -pub struct Request<'args, W> -where - W: Write, -{ +pub struct Request<'args> { pub content: &'args str, - pub writer: W, pub sections: &'args [String], + pub on_line: Option, } -pub fn execute(req: Request) -> Result<(), Error> -where - W: Write, -{ - let mut writer = BufWriter::new(req.writer); - +pub fn execute(req: Request) -> String { let choose_sections = req .sections .iter() .map(|s| Section::parse(s.as_str())) .collect::>(); - let mut current_sections: Option = None; + let mut current_section: Option = None; + let mut new_content = String::new(); for line in req.content.split_inclusive('\n') { let new_line = if is_section_end(line) { - current_sections = None; + current_section = None; line.to_string() } else if let Some(section_info) = line.strip_prefix("### ") { - current_sections = section_info.split_whitespace().next().map(|r| { + current_section = section_info.split_whitespace().next().map(|r| { let current_sections = r.split(',').map(Section::parse).collect::>(); SectionInfo { enable_variable: should_enable_variable(&choose_sections, ¤t_sections), @@ -69,7 +47,7 @@ where } }); line.to_string() - } else if let Some(section_info) = current_sections.as_ref() { + } else if let Some(section_info) = current_section.as_ref() { let trimmed_line = line.trim_start_matches(['#', ' ']); let is_var = is_variable(trimmed_line); if is_var && section_info.enable_variable { @@ -83,12 +61,13 @@ where line.to_string() }; - writer - .write_all(new_line.as_bytes()) - .map_err(|_| Error::WriteData)?; + new_content.push_str(&new_line); + if let Some(on_line) = req.on_line.as_ref() { + on_line(&new_line); + } } - writer.flush().map_err(|_| Error::WriteData) + new_content } fn is_variable(trimmed_line: &str) -> bool { @@ -144,28 +123,19 @@ fn should_disable_variable(choose_sections: &[Section], current_sections: &[Sect #[cfg(test)] mod tests { use super::*; - use std::io::Cursor; const BASE_ENV: &str = include_str!("../../test_data/base_env"); fn make_test(input: &str, expected_output: &str, sections: Vec<&str>) { - let mut output_data = vec![]; - let writer = Cursor::new(&mut output_data); - - match execute(Request { + let output_data = execute(Request { content: input, - writer, sections: §ions.into_iter().map(String::from).collect::>(), - }) { - Ok(()) => { - let output = String::from_utf8(output_data).unwrap(); - assert_eq!( - output.lines().collect::>(), - expected_output.lines().collect::>() - ); - } - _ => unreachable!(), - } + on_line: None, + }); + assert_eq!( + output_data.lines().collect::>(), + expected_output.lines().collect::>() + ); } #[test]