diff --git a/Cargo.lock b/Cargo.lock index b5ea128..814ffd1 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2,6 +2,185 @@ # It is not intended for manual editing. version = 4 +[[package]] +name = "anstream" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d" +dependencies = [ + "anstyle", + "anstyle-parse", + "anstyle-query", + "anstyle-wincon", + "colorchoice", + "is_terminal_polyfill", + "utf8parse", +] + +[[package]] +name = "anstyle" +version = "1.0.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000" + +[[package]] +name = "anstyle-parse" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e" +dependencies = [ + "utf8parse", +] + +[[package]] +name = "anstyle-query" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" +dependencies = [ + "windows-sys", +] + +[[package]] +name = "anstyle-wincon" +version = "3.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" +dependencies = [ + "anstyle", + "once_cell_polyfill", + "windows-sys", +] + +[[package]] +name = "clap" +version = "4.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d91e0c145792ef73a6ad36d27c75ac09f1832222a3c209689d90f534685ee5b7" +dependencies = [ + "clap_builder", + "clap_derive", +] + +[[package]] +name = "clap_builder" +version = "4.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f09628afdcc538b57f3c6341e9c8e9970f18e4a481690a64974d7023bd33548b" +dependencies = [ + "anstream", + "anstyle", + "clap_lex", + "strsim", +] + +[[package]] +name = "clap_derive" +version = "4.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d012d2b9d65aca7f18f4d9878a045bc17899bba951561ba5ec3c2ba1eed9a061" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "clap_lex" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" + +[[package]] +name = "colorchoice" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "is_terminal_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" + +[[package]] +name = "once_cell_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" + [[package]] name = "panini-cli" version = "0.1.0" +dependencies = [ + "clap", +] + +[[package]] +name = "proc-macro2" +version = "1.0.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + +[[package]] +name = "syn" +version = "3.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53e9bae58849f64dfa4f5d5ae372c8341f7305f82a3868709269343628b659a3" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "utf8parse" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] diff --git a/Cargo.toml b/Cargo.toml index bb8bfa0..6609879 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -24,3 +24,6 @@ path = "src/main.rs" [profile.release] opt-level = 2 + +[dependencies] +clap = { version = "4.5.61", features = ["derive"] } diff --git a/src/main.rs b/src/main.rs index 4c6bd34..5b0eacc 100644 --- a/src/main.rs +++ b/src/main.rs @@ -7,82 +7,101 @@ //! 4. compress the runtime + app into two content-addressed packs and embed //! them in a Zig self-extracting launcher (runtime is shared across apps) //! -//! std-only: shells out to `gleam`, `erl`, `tar`, `cp`, and `zig`. +//! Shells out to `gleam`, `erl`, `tar`, `cp`, and `zig`; `clap` drives the CLI. -use std::env; +use clap::{Args, Parser, Subcommand}; use std::fs; use std::path::{Path, PathBuf}; use std::process::{Command, Stdio}; mod otp; mod pipeline; +mod style; mod target; mod zig; +/// panini β€” press a Gleam (Erlang/BEAM) app into a single self-contained binary. πŸ₯ͺ +#[derive(Parser)] +#[command( + name = "panini", + version, + about = "πŸ₯ͺ panini β€” bundle a Gleam app and the Erlang/BEAM runtime into one self-contained binary that runs with nothing installed", + styles = style::clap_styles(), + arg_required_else_help = true +)] +struct Cli { + #[command(subcommand)] + command: Commands, +} + +#[derive(Subcommand)] +enum Commands { + /// Press a Gleam app into one self-contained binary + Build(BuildArgs), + /// Check the toolchain is ready + Doctor, + /// List supported build targets + Targets, + /// List OTP versions usable with --otp + OtpVersions, + /// Show the detected Gleam/OTP toolchain + Info, +} + +/// Options for `panini build`. +#[derive(Args)] +#[command(after_help = "\ +EXAMPLES: + panini build ./examples/hello -o ./hello + panini build ./examples/hello --otp 27.2 + panini build ./examples/hello --target x86_64-linux,aarch64-linux --otp 27.2 + panini build ./examples/hello --target all --otp 27.2 --compression xz")] +struct BuildArgs { + /// Gleam project directory + #[arg(value_name = "PROJECT_DIR", default_value = ".")] + project: PathBuf, + + /// Output binary (default: /) + #[arg(short, long, value_name = "PATH")] + output: Option, + + /// Bundle a precompiled OTP version, e.g. 27.2 (downloaded) + #[arg(long, value_name = "VERSION")] + otp: Option, + + /// Comma-separated targets, or 'all' (default: host) + #[arg(long, value_name = "LIST")] + target: Option, + + /// Payload compressor. xz/zst make smaller binaries but need that + /// decompressor present on Linux targets at run time + #[arg(long, value_name = "KIND", default_value_t = pipeline::Compression::Gz)] + compression: pipeline::Compression, +} + fn main() { - let args: Vec = env::args().collect(); - let cmd = args.get(1).map(String::as_str).unwrap_or("help"); + let cli = Cli::parse(); - let result = match cmd { - "build" => cmd_build(&args[2..]), - "info" => otp::print_info(), - "doctor" => cmd_doctor(), - "otp-versions" => otp::list_versions(), - "targets" => { + let result = match cli.command { + Commands::Build(args) => cmd_build(args), + Commands::Info => otp::print_info(), + Commands::Doctor => cmd_doctor(), + Commands::OtpVersions => otp::list_versions(), + Commands::Targets => { cmd_targets(); Ok(()) } - "help" | "-h" | "--help" => { - print_help(); - Ok(()) - } - "version" | "--version" | "-V" => { - // Compiled in from Cargo.toml at build time. - println!("panini {}", env!("CARGO_PKG_VERSION")); - Ok(()) - } - other => { - eprintln!("panini: unknown command '{other}'\n"); - print_help(); - std::process::exit(2); - } }; if let Err(e) = result { - eprintln!("\x1b[31merror:\x1b[0m {e}"); + eprintln!("{} {e}", style::error("error:")); std::process::exit(1); } } -fn print_help() { - println!( - "panini β€” a Burrito for Gleam πŸ₯ͺ\n\n\ - USAGE:\n \ - panini build [PROJECT_DIR] [OPTIONS] Press a Gleam app into one binary\n \ - panini doctor Check the toolchain is ready\n \ - panini targets List supported build targets\n \ - panini otp-versions List OTP versions usable with --otp\n \ - panini info Show detected Gleam/OTP toolchain\n \ - panini version Show the panini version\n \ - panini help Show this help\n\n\ - BUILD OPTIONS:\n \ - -o, --output PATH Output binary (default: /)\n \ - --otp VERSION Bundle a precompiled OTP version, e.g. 27.2 (downloaded)\n \ - --target LIST Comma-separated targets, or 'all' (default: host)\n \ - --compression KIND Payload compressor: gz (default), xz, or zst.\n \ - xz/zst yield smaller binaries but need that\n \ - decompressor present on Linux targets at run time.\n\n\ - EXAMPLES:\n \ - panini build ./examples/hello -o ./hello\n \ - panini build ./examples/hello --otp 27.2\n \ - panini build ./examples/hello --target x86_64-linux,aarch64-linux --otp 27.2\n \ - panini build ./examples/hello --target all --otp 27.2" - ); -} - /// Diagnose whether the toolchain needed to build is present. fn cmd_doctor() -> Result<(), String> { - println!("panini doctor\n"); + println!("{}\n", style::header("panini doctor")); let mut ok = true; // Fatal: without these, panini can't build at all. @@ -108,25 +127,31 @@ fn cmd_doctor() -> Result<(), String> { "io:format(\"OTP ~s\",[erlang:system_info(otp_release)]),halt().", ], ) { - Ok(v) => println!(" \x1b[32mβœ“\x1b[0m erl: {}", v.trim()), - Err(_) => { - println!(" \x1b[33mβ€’\x1b[0m erl: none (host-OTP builds unavailable; use --otp )") - } + Ok(v) => println!(" {} erl: {}", style::ok("βœ“"), style::cyan(v.trim())), + Err(_) => println!( + " {} erl: {}", + style::warn("β€’"), + style::muted("none (host-OTP builds unavailable; use --otp )") + ), } // Zig is not fatal β€” panini auto-downloads 0.16.0 if missing (needs curl+tar). - println!(" \x1b[36mβ€’\x1b[0m zig: {}", zig::describe()); + println!(" {} zig: {}", style::cyan("β€’"), zig::describe()); match target::host() { - Some(t) => println!(" {:<6} {}", "host:", t.name), + Some(t) => println!(" {} {}", style::muted("host:"), style::cyan(t.name)), None => { - println!(" {:<6} UNSUPPORTED (see `panini targets`)", "host:"); + println!( + " {} {}", + style::muted("host:"), + style::warn("UNSUPPORTED (see `panini targets`)") + ); ok = false; } } if ok { - println!("\n\x1b[32mβœ“ ready to build\x1b[0m"); + println!("\n{}", style::ok("βœ“ ready to build")); Ok(()) } else { Err("some required tools are missing (see above)".into()) @@ -138,87 +163,57 @@ fn check(program: &str, args: &[&str], hint: &str) -> bool { match capture(program, args) { Ok(out) => { let ver = out.lines().next().unwrap_or("").trim(); - println!(" \x1b[32mβœ“\x1b[0m {program}: {ver}"); + println!(" {} {program}: {}", style::ok("βœ“"), style::cyan(ver)); true } Err(_) => { - println!(" \x1b[31mβœ—\x1b[0m {program}: not found β€” {hint}"); + println!( + " {} {program}: {}", + style::error("βœ—"), + style::muted(&format!("not found β€” {hint}")) + ); false } } } fn cmd_targets() { - println!("supported targets:"); + println!("{}", style::header("supported targets")); let host = target::host(); for t in target::ALL { - let tag = if host.map(|h| h.name == t.name).unwrap_or(false) { - " (host)" + let is_host = host.map(|h| h.name == t.name).unwrap_or(false); + let tag = if is_host { + style::teal(" (host)") } else { - "" + String::new() }; - println!(" {:<16} zig: {}{}", t.name, t.zig, tag); + // Pad before coloring so ANSI codes don't throw off the column width. + let name = format!("{:<16}", t.name); + println!( + " {} {} {}{}", + style::cyan(&name), + style::muted("zig:"), + t.zig, + tag + ); } - println!("\nUse with: panini build --target [,...] (or --target all)"); - println!("Cross targets require --otp ."); + println!( + "\n{} panini build --target [,...] (or --target all)", + style::muted("use with:") + ); + println!("{}", style::muted("cross targets require --otp .")); } -/// Parse `build` args: project dir, `-o OUTPUT`, `--otp VERSION`, `--target LIST`. -fn cmd_build(args: &[String]) -> Result<(), String> { - let mut project = PathBuf::from("."); - let mut out: Option = None; - let mut otp_version: Option = None; - let mut target_spec: Option = None; - let mut compression = pipeline::Compression::Gz; - let mut i = 0; - let mut project_set = false; - while i < args.len() { - match args[i].as_str() { - "-o" | "--output" => { - i += 1; - out = Some(PathBuf::from( - args.get(i).ok_or("-o requires a path argument")?, - )); - } - "--otp" => { - i += 1; - otp_version = Some( - args.get(i) - .ok_or("--otp requires a version, e.g. 27.2")? - .clone(), - ); - } - "--target" => { - i += 1; - target_spec = Some( - args.get(i) - .ok_or("--target requires a value, e.g. all")? - .clone(), - ); - } - "--compression" | "--compress" => { - i += 1; - compression = pipeline::Compression::parse( - args.get(i) - .ok_or("--compression requires a value: gz, xz, or zst")?, - )?; - } - p if !p.starts_with('-') && !project_set => { - project = PathBuf::from(p); - project_set = true; - } - other => return Err(format!("unexpected argument: {other}")), - } - i += 1; - } - - let targets = resolve_targets(target_spec.as_deref())?; +/// Run a `panini build` from its parsed arguments. +fn cmd_build(args: BuildArgs) -> Result<(), String> { + let targets = resolve_targets(args.target.as_deref())?; - let project = project + let project = args + .project .canonicalize() - .map_err(|e| format!("project dir {}: {e}", project.display()))?; + .map_err(|e| format!("project dir {}: {e}", args.project.display()))?; - pipeline::build(&project, out, otp_version, targets, compression) + pipeline::build(&project, args.output, args.otp, targets, args.compression) } /// Turn a `--target` spec into a target list. `None` => host; "all" => everything. diff --git a/src/otp.rs b/src/otp.rs index d258bd0..d30b166 100644 --- a/src/otp.rs +++ b/src/otp.rs @@ -58,8 +58,11 @@ pub fn precompiled_root(version: &str, target: &Target, cache: &Path) -> Result< if !tgz.exists() { let url = target.otp_url(version); println!( - " downloading OTP {version} for {} (cached after first use)…", - target.name + " {}", + crate::style::muted(&format!( + "downloading OTP {version} for {} (cached after first use)…", + target.name + )) ); let part = tgz.with_extension("part"); crate::sh( @@ -144,7 +147,10 @@ pub fn fetch_musl(root: &Path, cache: &Path) -> Result { "https://beam-machine-universal.b-cdn.net/musl/libc-musl-{hash}.so\ ?please-respect-my-bandwidth-costs=thank-you" ); - println!(" fetching musl runtime for Linux (cached after first use)…"); + println!( + " {}", + crate::style::muted("fetching musl runtime for Linux (cached after first use)…") + ); let part = so.with_extension("part"); crate::sh( "curl", @@ -295,14 +301,17 @@ pub fn list_versions() -> Result<(), String> { versions.sort_by(|a, b| cmp_version(b, a)); // descending versions.dedup(); - println!("OTP versions available via --otp (newest first):\n"); + println!( + "{}\n", + crate::style::header("OTP versions available via --otp (newest first)") + ); let mut shown = 0; for v in &versions { // BEAM Machine precompiled builds start at 25.3. if cmp_version(v, "25.3") == std::cmp::Ordering::Less { continue; } - print!(" {v:<10}"); + print!(" {}", crate::style::teal(&format!("{v:<10}"))); shown += 1; if shown % 5 == 0 { println!(); @@ -311,8 +320,14 @@ pub fn list_versions() -> Result<(), String> { if shown % 5 != 0 { println!(); } - println!("\n precompiled runtimes: https://github.com/erlef/otp_builds (macOS builds are universal)"); - println!(" usage: panini build --otp [--target ]"); + println!( + "\n {}", + crate::style::muted("precompiled runtimes: https://github.com/erlef/otp_builds (macOS builds are universal)") + ); + println!( + " {}", + crate::style::muted("usage: panini build --otp [--target ]") + ); Ok(()) } @@ -360,11 +375,18 @@ fn cmp_version(a: &str, b: &str) -> std::cmp::Ordering { pub fn print_info() -> Result<(), String> { let gleam = capture("gleam", &["--version"]).unwrap_or_else(|_| "not found".into()); let root = root_dir()?; - println!("panini toolchain"); - println!(" gleam: {gleam}"); - println!(" otp release: {}", otp_release()?); - println!(" erts version: {}", erts_version()?); - println!(" otp root: {}", root.display()); - println!(" zig: {}", crate::zig::describe()); + let row = |label: &str, value: &str| { + println!( + " {} {}", + crate::style::muted(label), + crate::style::cyan(value) + ); + }; + println!("{}", crate::style::header("panini toolchain")); + row("gleam: ", &gleam); + row("otp release: ", &otp_release()?); + row("erts version:", &erts_version()?); + row("otp root: ", &root.display().to_string()); + row("zig: ", &crate::zig::describe()); Ok(()) } diff --git a/src/pipeline.rs b/src/pipeline.rs index 51b9426..52415cd 100644 --- a/src/pipeline.rs +++ b/src/pipeline.rs @@ -17,27 +17,22 @@ use std::path::{Path, PathBuf}; /// gz payload keeps the "runs on a machine with nothing installed" promise. `Xz` /// and `Zst` produce smaller binaries but require that decompressor to be present /// at run time on Linux targets (macOS `tar` has both built in via libarchive). -#[derive(Clone, Copy, Debug, PartialEq, Eq)] +#[derive(Clone, Copy, Debug, PartialEq, Eq, clap::ValueEnum)] pub enum Compression { + /// gzip β€” decompresses everywhere (default) + #[value(name = "gz", alias = "gzip")] Gz, + /// xz β€” smallest binary, needs `xz` on Linux targets + #[value(name = "xz")] Xz, + /// zstd β€” fast, needs `zstd` on Linux targets + #[value(name = "zst", alias = "zstd")] Zst, } impl Compression { - /// Parse a `--compression` value (accepts common aliases). - pub fn parse(s: &str) -> Result { - match s.to_ascii_lowercase().as_str() { - "gz" | "gzip" | "tgz" => Ok(Compression::Gz), - "xz" => Ok(Compression::Xz), - "zst" | "zstd" => Ok(Compression::Zst), - other => Err(format!( - "unknown compression '{other}' (expected: gz, xz, or zst)" - )), - } - } - /// Short name, used in messages and (informally) as the archive extension. + /// Matches the clap value name so it round-trips as a `--compression` value. pub fn ext(self) -> &'static str { match self { Compression::Gz => "gz", @@ -57,6 +52,12 @@ impl Compression { } } +impl std::fmt::Display for Compression { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(self.ext()) + } +} + struct Plan { /// OTP root to bundle for this target. root: PathBuf, @@ -76,8 +77,12 @@ pub fn build( let app = read_app_name(project)?; let multi = targets.len() > 1; println!( - "\x1b[1mπŸ₯ͺ panini\x1b[0m pressing '{app}' β†’ {} target{}\n", - targets.len(), + "{} {} {} {} β†’ {} target{}\n", + crate::style::gradient("πŸ₯ͺ panini"), + crate::style::muted(&format!("v{}", env!("CARGO_PKG_VERSION"))), + crate::style::muted("pressing"), + crate::style::cyan(&format!("'{app}'")), + crate::style::teal(&targets.len().to_string()), if multi { "s" } else { "" } ); @@ -100,7 +105,12 @@ pub fn build( (Some(v), None) => format!("bundling OTP {v}, compiled with host OTP"), (None, _) => "host OTP".into(), }; - println!("\n\x1b[1mβ–Έ {}\x1b[0m {compiled_with}", target.name); + println!( + "\n{} {} {}", + crate::style::teal("β–Έ"), + crate::style::header(target.name), + crate::style::muted(&compiled_with) + ); // The Linux OTP toolchain is musl-linked with a /tmp interpreter path; // install it before compiling with those binaries. @@ -147,10 +157,11 @@ pub fn build( let otp_tag = format!("{:08x}", djb2(rt_key.as_bytes())); let app_tag = format!("{app}-{:08x}", djb2(&app_bytes)); println!( - " payload: otp {} MB + app {} KB [{}, otp {otp_tag}]", - otp_bytes.len() / 1_048_576, - app_bytes.len() / 1024, - ext, + " {} otp {} + app {} {}", + crate::style::muted("payload:"), + crate::style::cyan(&format!("{} MB", otp_bytes.len() / 1_048_576)), + crate::style::cyan(&format!("{} KB", app_bytes.len() / 1024)), + crate::style::muted(&format!("[{ext}, otp {otp_tag}]")), ); build_launcher( @@ -165,10 +176,14 @@ pub fn build( target, &output, )?; - println!(" \x1b[32mβœ“\x1b[0m {}", output.display()); + println!( + " {} {}", + crate::style::ok("βœ“"), + crate::style::teal(&output.display().to_string()) + ); } - println!("\n\x1b[32mβœ“ done\x1b[0m"); + println!("\n{}", crate::style::gradient("βœ“ done")); Ok(()) } @@ -285,7 +300,10 @@ fn compile_shipment(project: &Path, toolbin: Option<&Path>) -> Result<(), String if toolbin.is_some() { let _ = fs::remove_dir_all(project.join("build/dev/erlang")); } - println!(" gleam export erlang-shipment"); + println!( + " {}", + crate::style::muted("gleam export erlang-shipment") + ); sh_env( "gleam", &["export", "erlang-shipment"], @@ -329,10 +347,14 @@ fn assemble_runtime( .map_err(|e| format!("bundle musl runtime: {e}"))?; } println!( - " runtime: {} + {} OTP lib apps{}", - erts_name, - lib_apps.len(), - if musl.is_some() { " + musl" } else { "" } + " {} {} {}", + crate::style::muted("runtime:"), + crate::style::cyan(&erts_name), + crate::style::muted(&format!( + "+ {} OTP lib apps{}", + lib_apps.len(), + if musl.is_some() { " + musl" } else { "" } + )), ); let musl_part = musl.map(|m| m.hash.as_str()).unwrap_or(""); Ok(format!("{erts_name}|{}|{musl_part}", app_names.join(","))) diff --git a/src/style.rs b/src/style.rs new file mode 100644 index 0000000..9c18d6f --- /dev/null +++ b/src/style.rs @@ -0,0 +1,126 @@ +//! panini's output palette: a **teal + cyan** theme with a violet accent. +//! +//! Colors are emitted as truecolor ANSI and suppressed automatically when +//! stdout isn't a terminal, when `NO_COLOR` is set, or under `TERM=dumb`, so +//! piped/redirected output stays clean. clap's help/usage/errors are themed to +//! match via [`clap_styles`]. + +use std::io::IsTerminal; +use std::sync::OnceLock; + +pub type Rgb = (u8, u8, u8); + +// ---- the theme ---- +pub const PRIMARY: Rgb = (0, 232, 198); // teal / mint β€” primary brand color +pub const SECONDARY: Rgb = (0, 198, 232); // cyan β€” values, secondary accent +pub const SKY: Rgb = (0, 210, 255); // sky blue β€” clap help headers / usage +pub const ACCENT: Rgb = (130, 100, 255); // violet β€” titles / rules +#[allow(dead_code)] +pub const HIGHLIGHT: Rgb = (100, 232, 130); // green β€” positive highlight +pub const LINK: Rgb = (255, 160, 100); // orange β€” links / attention +pub const MUTED: Rgb = (200, 210, 220); // light grey β€” secondary text +pub const ERROR: Rgb = (255, 100, 100); // red β€” errors + +/// Whether to emit color, decided once from the environment. +fn enabled() -> bool { + static ON: OnceLock = OnceLock::new(); + *ON.get_or_init(|| { + std::env::var_os("NO_COLOR").is_none() + && std::env::var_os("TERM") + .map(|t| t != "dumb") + .unwrap_or(true) + && std::io::stdout().is_terminal() + }) +} + +fn fg(c: Rgb) -> String { + format!("\x1b[38;2;{};{};{}m", c.0, c.1, c.2) +} + +/// Foreground-colored text (a no-op when color is disabled). +pub fn paint(c: Rgb, s: &str) -> String { + if enabled() { + format!("{}{s}\x1b[0m", fg(c)) + } else { + s.to_string() + } +} + +/// Bold, foreground-colored text. +pub fn bold(c: Rgb, s: &str) -> String { + if enabled() { + format!("\x1b[1m{}{s}\x1b[0m", fg(c)) + } else { + s.to_string() + } +} + +// ---- semantic shortcuts ---- +/// Primary (teal) accent β€” checkmarks, counts, key output. +pub fn teal(s: &str) -> String { + paint(PRIMARY, s) +} +/// Secondary (cyan) accent β€” values. +pub fn cyan(s: &str) -> String { + paint(SECONDARY, s) +} +/// A bold violet section header / title. +pub fn header(s: &str) -> String { + bold(ACCENT, s) +} +/// Success text or a βœ“ mark. +pub fn ok(s: &str) -> String { + paint(PRIMARY, s) +} +/// A notice / attention accent. +pub fn warn(s: &str) -> String { + paint(LINK, s) +} +/// Bold error text. +pub fn error(s: &str) -> String { + bold(ERROR, s) +} +/// Muted / secondary text. +pub fn muted(s: &str) -> String { + paint(MUTED, s) +} + +/// A left-to-right tealβ†’violet gradient across the characters of `s` (bold). +/// Used for the banner; degrades to plain text when color is disabled. +pub fn gradient(s: &str) -> String { + if !enabled() { + return s.to_string(); + } + let chars: Vec = s.chars().collect(); + let last = chars.len().saturating_sub(1).max(1) as f32; + let mut out = String::from("\x1b[1m"); + for (i, ch) in chars.iter().enumerate() { + let t = i as f32 / last; + out.push_str(&fg(( + lerp(PRIMARY.0, ACCENT.0, t), + lerp(PRIMARY.1, ACCENT.1, t), + lerp(PRIMARY.2, ACCENT.2, t), + ))); + out.push(*ch); + } + out.push_str("\x1b[0m"); + out +} + +fn lerp(a: u8, b: u8, t: f32) -> u8 { + (a as f32 + (b as f32 - a as f32) * t).round() as u8 +} + +/// clap's help/usage/error styling, themed to match. +pub fn clap_styles() -> clap::builder::Styles { + use clap::builder::styling::{Color, RgbColor, Style, Styles}; + let rgb = |c: Rgb| Color::Rgb(RgbColor(c.0, c.1, c.2)); + Styles::styled() + .header(Style::new().bold().fg_color(Some(rgb(SKY)))) + .usage(Style::new().bold().fg_color(Some(rgb(SKY)))) + .literal(Style::new().fg_color(Some(rgb(PRIMARY)))) + .placeholder(Style::new().fg_color(Some(rgb(SECONDARY)))) + .valid(Style::new().fg_color(Some(rgb(PRIMARY)))) + .invalid(Style::new().bold().fg_color(Some(rgb(LINK)))) + .error(Style::new().bold().fg_color(Some(rgb(ERROR)))) +}