ordinary 0.6.0-pre.14

Ordinary CLI
Documentation
// Copyright (C) 2026 Ordinary Labs, LLC.
//
// SPDX-License-Identifier: AGPL-3.0-only

use crate::units::{Time, UuidVersion};
use anyhow::bail;
use clap::Subcommand;
use fs_err::File;
use ordinary_build::PercentageDisplay;
use std::env::home_dir;
use std::io::Write;
use std::path::{Path, PathBuf};
use std::process::{Command, Output};
use time::UtcDateTime;
use uuid::Uuid;

#[derive(Subcommand, Debug)]
pub enum Utils {
    /// generate a UUID
    Uuid {
        /// uuid version
        #[arg(long, default_value = "4")]
        v: UuidVersion,
    },
    /// generate a UNIX timestamp for the current time (i.e. `date +%s`)
    Timestamp {
        /// unit of time
        #[arg(short, long, default_value = "seconds")]
        unit: Time,
        /// formatting (uses `time` crate <https://time-rs.github.io/book/api/format-description.html>)
        ///
        /// i.e. `"[year]/[month]/[day] [hour]:[minute]:[second]"`
        #[arg(short, long)]
        fmt: Option<String>,
    },
    /// utilities for managing HTML files
    Html {
        #[command(subcommand)]
        html: Html,
    },
    /// utilities for managing CSS files
    Css {
        #[command(subcommand)]
        css: Css,
    },
    /// utilities for managing JavaScript files
    Js {
        #[command(subcommand)]
        js: Js,
    },
    /// utilities for manipulating Markdown files
    Markdown {
        #[command(subcommand)]
        markdown: Markdown,
    },
    /// utilities for manipulating exif data
    Exif {
        #[command(subcommand)]
        exif: Exif,
    },
    /// [`wasm-opt`](https://github.com/WebAssembly/binaryen#wasm-opt) command
    WasmOpt {
        /// `wasm-opt` args: `ordinary utils wasm-opt -- --help`
        #[arg(trailing_var_arg = true, allow_hyphen_values = true)]
        args: Vec<String>,
    },
}

#[derive(Subcommand, Debug)]
pub enum Html {
    /// minify HTML files
    Minify {
        /// path to the HTML file
        path: PathBuf,
        /// whether it should overwrite the existing file
        #[arg(short, long, default_value_t = false)]
        in_place: bool,
    },
}

#[derive(Subcommand, Debug)]
pub enum Css {
    /// minify CSS files
    Minify {
        /// path to the CSS file
        path: PathBuf,
        /// whether it should overwrite the existing file
        #[arg(short, long, default_value_t = false)]
        in_place: bool,
    },
}

#[derive(Subcommand, Debug)]
pub enum Js {
    /// minify JavaScript files
    Minify {
        /// path to the JavaScript file
        path: PathBuf,
        /// whether it should overwrite the existing file
        #[arg(short, long, default_value_t = false)]
        in_place: bool,
    },
}

#[derive(Subcommand, Debug)]
pub enum Markdown {
    /// process and place an .html file next to the referenced .md file
    ToHtml {
        /// path to the Markdown file
        path: PathBuf,
        /// if `true` escape all HTML in the Markdown file
        #[arg(short, long, default_value_t = false)]
        safe: bool,
    },
}

#[derive(Subcommand, Debug)]
pub enum Exif {
    /// [`exiftool`](https://exiftool.org) command
    Tool {
        /// `exiftool` args: <https://exiftool.org/exiftool_pod.html>
        #[arg(trailing_var_arg = true, allow_hyphen_values = true)]
        args: Vec<String>,
    },
}

impl Utils {
    #[allow(clippy::redundant_else, clippy::too_many_lines)]
    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(fmt)?;
                    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, in_place } => {
                    minify(path, *in_place, ordinary_build::html::minify)?;
                }
            },
            Self::Css { css } => match css {
                Css::Minify { path, in_place } => {
                    minify(path, *in_place, ordinary_build::css::minify)?;
                }
            },
            Self::Js { js } => match js {
                Js::Minify { path, in_place } => {
                    minify(path, *in_place, ordinary_build::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::WasmOpt { args } => {
                let wasm_opt_path = home_dir()
                    .expect("home dir doesn't exist")
                    .join(".ordinary")
                    .join("bin")
                    .join("wasm-opt");

                if !wasm_opt_path.exists() {
                    tracing::warn!(
                        "wasm-opt not installed at {} (built WASM modules will not be further optimized) - for install, run `ordinary doctor --fix wasm-opt`",
                        wasm_opt_path.display()
                    );
                }

                let output = Command::new(wasm_opt_path).args(args).output()?;
                print_output(&output)?;
            }
        }

        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,
    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 !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(())
}