use crate::units::{Time, UuidVersion};
use anyhow::bail;
use clap::{Subcommand, ValueEnum};
use fs_err::File;
use ordinary_build::PercentageDisplay;
use qrcodegen::{QrCode, QrCodeEcc};
use std::collections::BTreeMap;
use std::env::home_dir;
use std::fmt::Write as _;
use std::io::Write;
use std::path::{Path, PathBuf};
use std::process::{Command, Output};
use time::UtcDateTime;
use tracing::instrument;
use uuid::Uuid;
#[derive(Subcommand, Debug)]
pub enum Utils {
Uuid {
#[arg(long, default_value = "4")]
v: UuidVersion,
},
#[clap(visible_aliases(["ts"]))]
Timestamp {
#[arg(short, long, default_value = "seconds")]
unit: Time,
#[arg(short, long)]
fmt: Option<String>,
},
Html {
#[command(subcommand)]
html: Html,
},
Css {
#[command(subcommand)]
css: Css,
},
Js {
#[command(subcommand)]
js: Js,
},
#[clap(visible_aliases(["md"]))]
Markdown {
#[command(subcommand)]
markdown: Markdown,
},
Exif {
#[command(subcommand)]
exif: Exif,
},
#[clap(visible_aliases(["qr"]))]
QrCode {
url: String,
#[arg(short, long)]
out: Option<PathBuf>,
#[arg(long)]
fmt: Option<QrCodeFmt>,
#[arg(long, default_value_t = false)]
dark: bool,
},
}
#[derive(Clone, Debug, ValueEnum)]
pub enum QrCodeFmt {
Svg,
}
#[derive(Subcommand, Debug)]
pub enum Html {
#[clap(visible_aliases(["min"]))]
Minify {
path: PathBuf,
#[arg(short, long)]
out: Option<PathBuf>,
#[arg(short, long, default_value_t = false)]
in_place: bool,
},
CspHashes {
path: PathBuf,
},
}
#[derive(Subcommand, Debug)]
pub enum Css {
#[clap(visible_aliases(["min"]))]
Minify {
path: PathBuf,
#[arg(short, long)]
out: Option<PathBuf>,
#[arg(short, long, default_value_t = false)]
in_place: bool,
},
}
#[derive(Subcommand, Debug)]
pub enum Js {
#[clap(visible_aliases(["min"]))]
Minify {
path: PathBuf,
#[arg(short, long)]
out: Option<PathBuf>,
#[arg(short, long, default_value_t = false)]
in_place: bool,
},
}
#[derive(Subcommand, Debug)]
pub enum Markdown {
ToHtml {
path: PathBuf,
#[arg(short, long, default_value_t = false)]
safe: bool,
},
}
#[derive(Subcommand, Debug)]
pub enum Exif {
Tool {
#[arg(trailing_var_arg = true, allow_hyphen_values = true)]
args: Vec<String>,
},
}
impl Utils {
#[allow(clippy::redundant_else, clippy::too_many_lines)]
#[instrument(skip_all, name = "utils")]
pub fn handle(&self) -> anyhow::Result<()> {
match self {
Self::Uuid { v } => match v {
UuidVersion::V4 => println!("{}", Uuid::new_v4()),
UuidVersion::V7 => println!("{}", Uuid::now_v7()),
},
Self::Timestamp { unit, fmt } => {
let mut timestamp = UtcDateTime::now();
timestamp = match unit {
Time::Seconds => timestamp.truncate_to_second(),
Time::Millis => timestamp.truncate_to_millisecond(),
Time::Micros => timestamp.truncate_to_microsecond(),
Time::Nanos => timestamp,
};
if let Some(fmt) = fmt {
let format_desc = time::format_description::parse_borrowed::<1>(fmt.as_str())?;
let formatted = timestamp.format(&format_desc)?;
println!("{formatted}");
} else {
let secs = timestamp.unix_timestamp();
let nanos = timestamp.unix_timestamp_nanos();
match unit {
Time::Seconds => println!("{secs}"),
Time::Millis => {
println!("{}", (secs * 1000) + (i64::try_from(nanos)? / 1000 / 1000));
}
Time::Micros => {
println!("{}", (secs * 1000 * 1000) + (i64::try_from(nanos)? / 1000));
}
Time::Nanos => println!("{nanos}"),
}
}
}
Self::Html { html } => match html {
Html::Minify {
path,
out,
in_place,
} => {
minify(
path,
out.as_deref(),
*in_place,
ordinary_build_utils::html::minify,
)?;
}
Html::CspHashes { path } => {
let html = fs_err::read_to_string(path)?;
let csp_hashes = ordinary_build_utils::csp::for_cli(&html, true, false);
let mut map = BTreeMap::new();
map.insert("script", csp_hashes.script_src_inline_hashes);
map.insert("style", csp_hashes.style_src_inline_hashes);
println!("{}", serde_json::to_string(&map)?);
}
},
Self::Css { css } => match css {
Css::Minify {
path,
out,
in_place,
} => {
minify(
path,
out.as_deref(),
*in_place,
ordinary_build_utils::css::minify,
)?;
}
},
Self::Js { js } => match js {
Js::Minify {
path,
out,
in_place,
} => {
minify(
path,
out.as_deref(),
*in_place,
ordinary_build_utils::js::minify,
)?;
}
},
Self::Markdown { markdown } => match markdown {
Markdown::ToHtml { path, safe } => {
use pulldown_cmark::{Options, Parser};
if *safe {
todo!("implement safe");
}
if path.is_dir() {
bail!("doesn't yet work for directories")
} else {
let md = fs_err::read_to_string(path)?;
let options = Options::all();
let parser = Parser::new_ext(&md, options);
let mut path = path.clone();
path.set_extension("html");
let mut file = File::create(path)?;
pulldown_cmark::html::write_html_io(&mut file, parser)?;
file.flush()?;
}
}
},
Self::Exif { exif } => match exif {
Exif::Tool { args } => {
let exiftool_path = home_dir()
.expect("home dir doesn't exist")
.join(".ordinary")
.join("bin")
.join("exiftool")
.join("exiftool");
if !exiftool_path.exists() {
bail!(
"`exiftool` not installed for `ordinary` — for install, run `ordinary doctor --fix exiftool`"
);
}
let output = Command::new(exiftool_path).args(args).output()?;
print_output(&output)?;
}
},
Self::QrCode {
url,
out,
fmt,
dark,
} => {
let qr: QrCode = QrCode::encode_text(url, QrCodeEcc::Medium)?;
if let Some(out) = out
&& let Some(parent) = out.parent()
{
let fmt = fmt.to_owned().unwrap_or(QrCodeFmt::Svg);
match fmt {
QrCodeFmt::Svg => {
let svg = to_svg_string(&qr, *dark)?;
fs_err::create_dir_all(parent)?;
fs_err::write(out, svg.as_bytes())?;
}
}
} else {
let border: i32 = 1;
for y in -border..qr.size() + border {
for x in -border..qr.size() + border {
let c: char = if qr.get_module(x, y) { 'â–ˆ' } else { ' ' };
print!("{c}{c}");
}
println!();
}
}
}
}
Ok(())
}
}
fn print_output(output: &Output) -> anyhow::Result<()> {
if output.status.success() {
for line in std::str::from_utf8(&output.stderr)?.split('\n') {
if !line.trim().is_empty() {
tracing::info!(stderr = %line);
}
}
println!("{}", std::str::from_utf8(&output.stdout)?);
} else {
for line in std::str::from_utf8(&output.stderr)?.split('\n') {
if !line.trim().is_empty() {
tracing::error!(stderr = %line);
}
}
println!("{}", std::str::from_utf8(&output.stdout)?);
}
Ok(())
}
#[allow(clippy::redundant_else, clippy::cast_precision_loss)]
fn minify(
path: &Path,
out: Option<&Path>,
in_place: bool,
minify: fn(file_str: &str) -> anyhow::Result<String>,
) -> anyhow::Result<()> {
let mut path = path.to_path_buf();
if path.is_dir() {
bail!("doesn't yet work for directories")
} else {
let file_str = fs_err::read_to_string(&path)?;
let minified = minify(&file_str)?;
if let Some(out) = out {
if let Some(parent) = out.parent() {
fs_err::create_dir_all(parent)?;
}
fs_err::write(out, minified.as_bytes())?;
} else {
if !in_place {
let ext = if let Some(ext) = path.extension() {
format!("min.{}", ext.display())
} else {
"min".to_string()
};
path.set_extension(ext);
}
let mut file = File::create(path)?;
file.write_all(minified.as_bytes())?;
file.flush()?;
}
tracing::info!(
size.source = %bytesize::ByteSize(file_str.len() as u64).display().si_short(),
size.minified = %bytesize::ByteSize(minified.len() as u64).display().si_short(),
size.reduction = %PercentageDisplay(((file_str.len() as f64 - minified.len() as f64)
/ file_str.len() as f64)
* 100.0),
);
}
Ok(())
}
fn to_svg_string(qr: &QrCode, dark: bool) -> anyhow::Result<String> {
let mut result = String::new();
let dimension = qr.size();
let background_color = if dark { "#000000" } else { "#FFFFFF" };
let code_color = if dark { "#FFFFFF" } else { "#000000" };
writeln!(
result,
"<svg xmlns=\"http://www.w3.org/2000/svg\" version=\"1.1\" viewBox=\"0 0 {dimension} {dimension}\" stroke=\"none\">"
)?;
writeln!(
result,
"\t<rect width=\"100%\" height=\"100%\" fill=\"{background_color}\"/>\n"
)?;
result += "\t<path d=\"";
for y in 0..dimension {
for x in 0..dimension {
if qr.get_module(x, y) {
if x != 0 || y != 0 {
result += " ";
}
write!(result, "M{x},{y}h1v1h-1z")?;
}
}
}
writeln!(result, "\" fill=\"{code_color}\"/>\n</svg>")?;
Ok(result)
}