diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 88f4cfc..368d205 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -14,7 +14,7 @@ jobs: with: mono-dev: packages rust: stable - - run: task exec -- check + - run: task check test: runs-on: ubuntu-latest steps: @@ -22,7 +22,7 @@ jobs: with: mono-dev: packages rust: stable - - run: task exec -- test + - run: task test doc: runs-on: ubuntu-latest steps: @@ -30,4 +30,4 @@ jobs: with: mono-dev: packages rust: nightly - - run: task exec -- doc + - run: task doc diff --git a/Cargo.toml b/Cargo.toml index 5501f80..72d6dd1 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -4,4 +4,5 @@ members = [ "packages/copper", "packages/copper-proc-macros", "packages/promethium", + "packages/terminal-tests", ] diff --git a/Taskfile.yml b/Taskfile.yml index c964b13..e905d7e 100644 --- a/Taskfile.yml +++ b/Taskfile.yml @@ -14,6 +14,7 @@ includes: cu: {taskfile: ./packages/copper, dir: ./packages/copper, internal: true} cu-proc-macros: {taskfile: ./packages/copper-proc-macros, dir: ./packages/copper-proc-macros, internal: true} pm: {taskfile: ./packages/promethium, dir: ./packages/promethium, internal: true} + terminal-tests: {taskfile: ./packages/terminal-tests, dir: ./packages/terminal-tests, internal: true} tasks: install-cargo-extra-tools: @@ -33,11 +34,13 @@ tasks: - task: pm:check - task: cu-proc-macros:check - task: cu:check + - task: terminal-tests:check test: cmds: - task: pm:test - task: cu:test + - task: terminal-tests:run dev-doc: cmds: diff --git a/packages/copper-proc-macros/Cargo.toml b/packages/copper-proc-macros/Cargo.toml index d090807..ed2dd20 100644 --- a/packages/copper-proc-macros/Cargo.toml +++ b/packages/copper-proc-macros/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "pistonite-cu-proc-macros" -version = "0.2.4" +version = "0.2.5" edition = "2024" description = "Proc-macros for Cu" repository = "https://github.com/Pistonite/cu" @@ -12,7 +12,7 @@ exclude = [ [dependencies.pm] package = "pistonite-pm" -version = "0.2.3" +version = "0.2.4" path = "../promethium" features = ["full"] diff --git a/packages/copper-proc-macros/src/cli.rs b/packages/copper-proc-macros/src/cli.rs index fa0c93c..c5d7a0a 100644 --- a/packages/copper-proc-macros/src/cli.rs +++ b/packages/copper-proc-macros/src/cli.rs @@ -41,10 +41,14 @@ pub fn expand(attr: TokenStream, input: TokenStream) -> pm::Result } }; - item.sig.ident = generated_main_name; + let old_name = { + let mut new_name = generated_main_name; + std::mem::swap(&mut new_name, &mut item.sig.ident); + new_name + }; let expanded = pm::quote! { - fn main() -> std::process::ExitCode { + fn #old_name() -> std::process::ExitCode { unsafe { #main_impl } } #item diff --git a/packages/copper-proc-macros/src/error_ctx.rs b/packages/copper-proc-macros/src/context.rs similarity index 100% rename from packages/copper-proc-macros/src/error_ctx.rs rename to packages/copper-proc-macros/src/context.rs diff --git a/packages/copper-proc-macros/src/lib.rs b/packages/copper-proc-macros/src/lib.rs index 9803a4a..eb2eaa9 100644 --- a/packages/copper-proc-macros/src/lib.rs +++ b/packages/copper-proc-macros/src/lib.rs @@ -1,6 +1,143 @@ use pm::pre::*; -/// See documentation for [`cu::cli`](../pistonite-cu/cli/index.html) module +/// **For the Command Line Interface feature set, +/// please refer to the [`cu::cli`](../pistonite-cu/cli/index.html) module.** +/// +/// This is the documentation for the `#[cu::cli]` macro. +/// +/// By annotating the main function, this macro generates +/// a shim that will reference the `cu::cli::Flags` command line +/// arguments and initialize logging, printing, and prompting +/// systems accordingly. +/// +/// The main function can be async or sync. It should +/// return a `cu::Result` +/// ```rust,ignore +/// #[cu::cli] +/// fn main(flags: cu::cli::Flags) -> cu::Result<()> { +/// cu::debug!("flags are {flags:?}"); +/// Ok(()) +/// } +/// ``` +/// +/// To also define your own flags using the [`clap`](https://docs.rs/clap) +/// crate, define a CLI struct that derives `clap::Parser`. +/// Note the prelude import (`cu::pre::*`) automatically +/// brings `clap` into scope. You don't even need to add it +/// to `Cargo.toml`! +/// +/// Make sure to `#[clap(flatten)]` the flags into your struct. +/// +/// ```rust,ignore +/// # use pistonite_cu as cu; +/// use cu::pre::*; +/// /// My program +/// /// +/// /// This is my program, it is very good. +/// #[derive(clap::Parser, Clone)] +/// struct Args { +/// /// Input of the program +/// #[clap(short, long)] +/// input: String, +/// /// Output of the program +/// #[clap(short, long)] +/// output: Option, +/// #[clap(flatten)] +/// inner: cu::cli::Flags, +/// } +/// ``` +/// Now, to tell `cu` where to look for the flags, +/// specify the name of the field with `flags = "field"` +/// ```rust,ignore +/// // use the flags attribute to refer to the cu::cli::Flags field inside the Args struct +/// #[cu::cli(flags = "inner")] +/// fn main(args: Args) -> cu::Result<()> { +/// cu::info!("input is {}", args.input); +/// cu::info!("output is {:?}", args.output); +/// Ok(()) +/// } +/// ``` +/// +/// Alternatively, implement `AsRef` for your struct. +/// +/// ```rust,ignore +/// # use pistonite_cu as cu; +/// use cu::pre::*; +/// #[derive(clap::Parser, Clone)] +/// struct Args { +/// input: String, +/// #[clap(flatten)] +/// inner: cu::cli::Flags, +/// } +/// impl AsRef for Args { +/// fn as_ref(&self) -> cu::cli::Flags { +/// &self.inner +/// } +/// } +/// #[cu::cli] +/// fn main(_: Args) -> cu::Result<()> { +/// Ok(()) +/// } +/// ``` +/// +/// Or enable the `derive` feature and derive `AsRef` (via [`derive_more`](https://docs.rs/derive_more)). +/// ```rust,ignore +/// # use pistonite_cu as cu; +/// use cu::pre::*; +/// #[derive(clap::Parser, Clone, AsRef)] +/// struct Args { +/// input: String, +/// #[clap(flatten)] +/// #[as_ref] +/// inner: cu::cli::Flags, +/// } +/// #[cu::cli] +/// fn main(_: Args) -> cu::Result<()> { +/// Ok(()) +/// } +/// ``` +/// +/// The attribute can also take a `preprocess` function +/// to process flags before initializing the CLI system. +/// This can be useful to merge multiple Flags instance +/// in the CLI. Note that the logging/printing system +/// will not work during the preprocess. +/// +/// ```rust,ignore +/// # use pistonite_cu as cu; +/// use cu::pre::*; +/// +/// #[derive(clap::Parser)] +/// struct Args { +/// #[clap(subcommand)] +/// subcommand: Option, +/// #[clap(flatten)] +/// inner: cu::cli::Flags, +/// } +/// impl Args { +/// fn preprocess(&mut self) { +/// // merge subcommand flags into top level flags +/// // this way, both `-v foo` and `foo -v` will work +/// if let Some(Command::Foo(c)) = &self.subcommand { +/// self.inner.merge(c); +/// } +/// } +/// } +/// impl AsRef for Args { +/// fn as_ref(&self) -> &cu::cli::Flags { +/// &self.inner +/// } +/// } +/// #[derive(clap::Subcommand)] +/// enum Command { +/// Foo(cu::cli::Flags), +/// } +/// #[cu::cli(preprocess = Args::preprocess)] +/// fn main(args: Args) -> cu::Result<()> { +/// Ok(()) +/// } +/// ``` +/// #[proc_macro_attribute] pub fn cli(attr: TokenStream, input: TokenStream) -> TokenStream { pm::flatten(cli::expand(attr, input)) @@ -16,10 +153,10 @@ mod derive_parse; /// Attribute macro for wrapping a function with an error context /// -/// See the [tests](https://github.com/Pistonite/cu/blob/main/packages/copper/tests/error_ctx.rs) +/// See the [tests](https://github.com/Pistonite/cu/blob/main/packages/copper/tests/context.rs) /// for examples #[proc_macro_attribute] -pub fn error_ctx(attr: TokenStream, input: TokenStream) -> TokenStream { - pm::flatten(error_ctx::expand(attr, input)) +pub fn context(attr: TokenStream, input: TokenStream) -> TokenStream { + pm::flatten(context::expand(attr, input)) } -mod error_ctx; +mod context; diff --git a/packages/copper/Cargo.toml b/packages/copper/Cargo.toml index 61b7d7a..4d87f04 100644 --- a/packages/copper/Cargo.toml +++ b/packages/copper/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "pistonite-cu" -version = "0.6.10" +version = "0.7.0" edition = "2024" description = "Battery-included common utils to speed up development of rust tools" repository = "https://github.com/Pistonite/cu" @@ -11,49 +11,51 @@ exclude = [ ] [dependencies] -pistonite-cu-proc-macros = { version = "0.2.4", path = "../copper-proc-macros" } +pistonite-cu-proc-macros = { version = "0.2.5", path = "../copper-proc-macros" } +# --- Always enabled --- anyhow = "1.0.100" log = "0.4.29" -oneshot = "0.1.11" -# printing/cli +# --- Command Line Interface --- +oneshot = { version = "0.1.11", optional = true } env_filter = { version = "0.1.4", optional = true } terminal_size = { version = "0.4.3", optional = true } unicode-width = { version = "0.2.2", features = ["cjk"], optional = true } clap = { version = "4.5.54", features = ["derive"], optional = true } regex = { version = "1.12.2", optional = true } -# fs +# --- Coroutine --- +num_cpus = { version = "1.17.0", optional = true } +tokio = { version = "1.49.0", optional = true, features = [ + "macros", "rt-multi-thread" +] } + +# --- File System --- dunce = {version="1.0.5", optional = true} which = {version = "8.0.0", optional = true } pathdiff = {version = "0.2.3", optional=true} -spin = {version = "0.10.0", optional = true} filetime = { version = "0.2.26", optional = true} glob = { version = "0.3.3", optional = true } +spin = {version = "0.10.0", optional = true} # for PIO # serde serde = { version = "1.0.228", features = ["derive"], optional = true } -serde_json = { version = "1.0.148", optional = true } +serde_json = { version = "1.0.149", optional = true } serde_yaml_ng = { version = "0.10.0", optional = true } -toml = { version = "0.9.10", optional = true } +toml = { version = "0.9.11", optional = true } # derive derive_more = { version = "2.1.1", features = ["full"], optional = true } -# coroutine -num_cpus = { version = "1.17.0", optional = true } - [target.'cfg(unix)'.dependencies] -libc = "0.2.179" +libc = "0.2.180" [target.'cfg(windows)'.dependencies] windows-sys = { version = "0.61.2", features = ["Win32_Foundation", "Win32_System_Console", "Win32_Storage_FileSystem", "Win32_Security", "Win32_System_SystemServices"] } -[dependencies.tokio] -version = "1.49.0" -features = [ "macros", "rt-multi-thread" ] -optional = true +[dev-dependencies] + [dev-dependencies.tokio] version = "1.49.0" features = [ "macros", "rt-multi-thread", "time" ] @@ -61,8 +63,8 @@ features = [ "macros", "rt-multi-thread", "time" ] [features] default = [] full = [ - "coroutine-heavy", "cli", + "coroutine-heavy", "prompt-password", "process", "json", @@ -71,31 +73,31 @@ full = [ "derive", ] -# Command Line Interface (enables integration with `clap` and command line entry points) +# --- Command Line Interface --- +print = ["dep:oneshot", "dep:regex", "dep:env_filter", "dep:terminal_size", "dep:unicode-width"] cli = ["dep:clap", "print"] -print = ["dep:regex", "dep:env_filter", "dep:terminal_size", "dep:unicode-width"] -# Utils to show prompt for user input in terminal prompt = ["print"] prompt-password = ["prompt"] -# Enable coroutine drivers, which allow interop with async + +# --- Coroutine --- coroutine = [ "dep:tokio", "dep:num_cpus", "tokio/sync", "tokio/io-util", "tokio/io-std" ] -# Enable heavy coroutine drived by multi-threaded tokio runtime -coroutine-heavy = ["coroutine"] -# Enable spawning child processes -process = [ +coroutine-heavy = ["coroutine"] # enable heavy coroutine drived by multi-threaded tokio runtime + +# --- File System --- +process = [ # enable spawning child processes "coroutine", "fs", "dep:spin", "tokio/process", "tokio/time", ] -# Enable file system and path util -fs = [ +fs = [ # enable file system and path util "dep:which", "dep:pathdiff", "dep:dunce", "dep:filetime", "dep:glob", "tokio?/fs" ] + # Enable parsing utils parse = [] serde = ["dep:serde"] @@ -114,9 +116,12 @@ release-nolog= ["log/release_max_level_off"] release-nodebuglog = ["log/release_max_level_info"] derive = ["dep:derive_more"] +# Internally used to enable test features +__test = [] + [[example]] -name = "print" -required-features = ["prompt", "cli"] +name = "prompt_password" +required-features = ["prompt-password", "cli", "parse"] [[example]] name = "process" @@ -129,7 +134,3 @@ required-features = ["fs", "cli"] [[example]] name = "cargo" required-features = ["process", "cli", "json"] - -[[example]] -name = "prompt_password" -required-features = ["cli", "prompt-password"] diff --git a/packages/copper/Taskfile.yml b/packages/copper/Taskfile.yml index 3699d53..1a1faa6 100644 --- a/packages/copper/Taskfile.yml +++ b/packages/copper/Taskfile.yml @@ -8,69 +8,70 @@ includes: tasks: check: - cmds: - - task: cargo:clippy-package-feature - vars: - PACKAGE: pistonite-cu - FEATURES: default - - task: cargo:clippy-package-feature - vars: - PACKAGE: pistonite-cu - FEATURES: print - - task: cargo:clippy-package-feature - vars: - PACKAGE: pistonite-cu - FEATURES: cli - - task: cargo:clippy-package-feature - vars: - PACKAGE: pistonite-cu - FEATURES: prompt - - task: cargo:clippy-package-feature - vars: - PACKAGE: pistonite-cu - FEATURES: prompt,cli - - task: cargo:clippy-package-feature - vars: - PACKAGE: pistonite-cu - FEATURES: prompt-password,cli - - task: cargo:clippy-package-feature - vars: - PACKAGE: pistonite-cu - FEATURES: cli,coroutine - - task: cargo:clippy-package-feature - vars: - PACKAGE: pistonite-cu - FEATURES: fs - - task: cargo:clippy-package-feature - vars: - PACKAGE: pistonite-cu - FEATURES: process - - task: cargo:clippy-package-feature - vars: - PACKAGE: pistonite-cu - FEATURES: parse - - task: cargo:clippy-package-feature - vars: - PACKAGE: pistonite-cu - FEATURES: json,yaml,toml - - task: cargo:clippy-package-feature - vars: - PACKAGE: pistonite-cu - FEATURES: full - - task: cargo:fmt-check - - cmd: echo "checking default feature"; (grep "^default = \[\]$" Cargo.toml || rg "^default = \[\]$" cargo.toml) + - task: cargo:clippy-package-feature + vars: + PACKAGE: pistonite-cu + FEATURES: default + - task: cargo:clippy-package-feature + vars: + PACKAGE: pistonite-cu + FEATURES: print + - task: cargo:clippy-package-feature + vars: + PACKAGE: pistonite-cu + FEATURES: cli + - task: cargo:clippy-package-feature + vars: + PACKAGE: pistonite-cu + FEATURES: prompt + - task: cargo:clippy-package-feature + vars: + PACKAGE: pistonite-cu + FEATURES: prompt-password + - task: cargo:clippy-package-feature + vars: + PACKAGE: pistonite-cu + FEATURES: prompt,cli + - task: cargo:clippy-package-feature + vars: + PACKAGE: pistonite-cu + FEATURES: prompt-password,cli + - task: cargo:clippy-package-feature + vars: + PACKAGE: pistonite-cu + FEATURES: cli,coroutine + - task: cargo:clippy-package-feature + vars: + PACKAGE: pistonite-cu + FEATURES: fs + - task: cargo:clippy-package-feature + vars: + PACKAGE: pistonite-cu + FEATURES: process + - task: cargo:clippy-package-feature + vars: + PACKAGE: pistonite-cu + FEATURES: parse + - task: cargo:clippy-package-feature + vars: + PACKAGE: pistonite-cu + FEATURES: json,yaml,toml + - task: cargo:clippy-package-feature + vars: + PACKAGE: pistonite-cu + FEATURES: full + - task: cargo:fmt-check + - cmd: echo "checking default feature"; (grep "^default = \[\]$" Cargo.toml || rg "^default = \[\]$" Cargo.toml) fix: - cmds: - - task: cargo:fmt-fix + - task: cargo:fmt-fix test: - cmds: - - cargo test - - cargo test --features cli - - cargo test --features full + - cargo test + - cargo test --features cli + - cargo test --features full + publish: - cmds: - - cmd: cargo publish - ignore_error: true + - cmd: cargo publish + ignore_error: true diff --git a/packages/copper/examples/cargo.rs b/packages/copper/examples/cargo.rs index b8ac044..1e59bf3 100644 --- a/packages/copper/examples/cargo.rs +++ b/packages/copper/examples/cargo.rs @@ -12,15 +12,14 @@ struct Cli { #[cu::cli(flags = "common")] fn main(args: Cli) -> cu::Result<()> { cu::info!("invoking cargo"); - cu::which("cargo")? + let (child, bar) = cu::which("cargo")? .command() .args(["build"]) - .name("cargo build") .current_dir(args.path) .add(cu::color_flag()) - .preset(cu::pio::cargo()) - .spawn()? - .0 - .wait_nz()?; + .preset(cu::pio::cargo("cargo build")) + .spawn()?; + child.wait_nz()?; + bar.done(); Ok(()) } diff --git a/packages/copper/examples/print.rs b/packages/copper/examples/print.rs deleted file mode 100644 index e541a2e..0000000 --- a/packages/copper/examples/print.rs +++ /dev/null @@ -1,118 +0,0 @@ -use pistonite_cu as cu; -use std::time::Duration; - -use cu::pre::*; - -#[derive(clap::Parser, Clone)] -struct Args { - #[clap(flatten)] - inner: cu::cli::Flags, -} -impl Args { - fn preprocess(&mut self) { - self.inner.verbose += 1; - println!("{:#?}", self.inner); - } -} -/// Run with cargo run --example print --features prompt,cli -#[cu::cli(flags = "inner", preprocess = Args::preprocess)] -fn main(_: Args) -> cu::Result<()> { - cu::print!("today's weather is {}", "good"); - cu::hint!("today's weather is {}", "ok"); - cu::info!( - "this is an info messagenmultilineaa 你好 sldkfjals🤖kdjflkasjdflkjasldkfjaklsdjflkjasldkfjlaksjdflkajsdklfjlaksjdfkljasldkfjlasldkjflaskdjflaksjdlfkajsldkfjkasjdlfkjaskldjflajsdlkfjlaskjdfklajsdf" - ); - cu::warn!("this is a warn message\n"); - cu::error!("this is error message\n\n"); - cu::debug!("this is debug message\n2\n\n"); - cu::trace!("this is trace message\n\n2\n"); - if !cu::yesno!("continue?")? { - cu::warn!("you chose to not continue!"); - return Ok(()); - } - cu::info!("you chose to continue!"); - - { - let bar2 = cu::progress_bar(20, "This takes 5 seconds"); - let bar = cu::progress_unbounded("This is unbounded"); - for i in 0..10 { - cu::progress!(&bar, (), "step {i}"); - cu::progress!(&bar2, i, "step {i}"); - cu::debug!("this is debug message\n"); - std::thread::sleep(Duration::from_millis(250)); - } - drop(bar); - for i in 0..10 { - cu::progress!(&bar2, i + 10, "step {}", i + 10); - std::thread::sleep(Duration::from_millis(250)); - cu::print!("doing stuff"); - } - } - - let thread1 = std::thread::spawn(|| { - cu::set_thread_print_name("t1"); - let answer = cu::prompt!("from thread 1")?; - cu::info!("you entered: {answer}"); - cu::Ok(()) - }); - let thread2 = std::thread::spawn(|| { - cu::set_thread_print_name("t2"); - let answer = cu::prompt!("from thread 2")?; - cu::info!("you entered: {answer}"); - cu::Ok(()) - }); - let thread3 = std::thread::spawn(|| { - cu::set_thread_print_name("t3"); - let answer = cu::prompt!("from thread 3")?; - cu::info!("you entered: {answer}"); - cu::Ok(()) - }); - let r1 = thread1.join().unwrap(); - let r2 = thread2.join().unwrap(); - let r3 = thread3.join().unwrap(); - r1?; - r2?; - r3?; - cu::info!("all threads joined ok"); - - let command = cu::prompt!("enter command")?; - // note: in a real-world application, you would use something like - // the `shell_words` crate to split the input - let args: AnotherArgs = cu::check!( - cu::cli::try_parse(command.split_whitespace()), - "error parsing args" - )?; - cu::print!("parsed args: {args:?}"); - // note: in a real-world application, this will probably be some subcommand - if args.help { - cu::cli::print_help::(true); - } - - Ok(()) -} - -/// Test Another Arg -/// -/// long text here -#[derive(Debug, clap::Parser)] -#[clap( - name = "", - no_binary_name = true, - disable_help_flag = true, - disable_version_flag = true -)] -struct AnotherArgs { - /// the file - /// - /// long text here - pub file: String, - /// If we should copy - /// - /// long text here - #[clap(short, long)] - pub copy: bool, - - /// HELP ME - #[clap(short, long, conflicts_with = "copy")] - pub help: bool, -} diff --git a/packages/copper/examples/prompt_password.rs b/packages/copper/examples/prompt_password.rs index 30c31d2..cff4573 100644 --- a/packages/copper/examples/prompt_password.rs +++ b/packages/copper/examples/prompt_password.rs @@ -1,21 +1,80 @@ use pistonite_cu as cu; -use cu::pre::*; +// can only manually test this because password not prompted from stdin +#[cu::cli] +fn main(_: cu::cli::Flags) -> cu::Result<()> { + if !cu::yesno!("do you want to continue?")? { + return Ok(()); + } + cu::info!("user picked yes"); -#[derive(clap::Parser, Clone)] -struct Args { - #[clap(flatten)] - inner: cu::cli::Flags, -} + let name = cu::prompt!("please enter your name")?; + cu::info!("hi, {name}"); + let password = cu::prompt_password!("please enter your password")?; + cu::info!("user entered: {password}"); + + let expected = "rust"; + let answer = cu::prompt_validate!( + ( + "what's your favorite programming language? please answer {}", + expected + ), + |answer| { + if answer == expected { + return Ok(true); + } + if answer == "javascript" { + cu::bail!("that's not good"); + } + cu::error!("try again"); + Ok(false) + } + )?; + cu::ensure!(answer == expected)?; + let mut index: i32 = 0; + cu::prompt_validate!("select a number between 0 and 5", |answer| { + let number = match cu::parse::(answer) { + Err(e) => { + cu::error!("{e}"); + cu::hint!("please ensure you are entering a number"); + return Ok(false); + } + Ok(x) => x, + }; + if number < 0 { + cu::error!("the number you entered is too small"); + return Ok(false); + } + if number > 5 { + cu::error!("the number you entered is too big"); + return Ok(false); + } + index = number; + Ok(true) + })?; + cu::info!("index is {index}"); -#[cu::cli(flags = "inner")] -fn main(_: Args) -> cu::Result<()> { - let name = cu::prompt!("name")?; - cu::info!("name is: {name}"); - // type for pw is ZeroWhenDropString - let pw = cu::prompt_password!("password")?; - cu::info!("password is: {pw}"); - let pw2 = cu::prompt_legal_password!("legal password")?; - cu::info!("password is: {pw2}"); + let password = cu::prompt_password_validate!( + "please enter a password between 8 and 16 charactres and only contain sensible characters", + |answer| { + if answer == "123456" { + cu::bail!("how can you do that, bye"); + } + if answer.len() < 8 { + cu::error!("password is too short"); + return Ok(false); + } + if answer.len() > 16 { + cu::error!("password is too long"); + return Ok(false); + } + if let Err(e) = cu::password_chars_legal(answer) { + cu::error!("invalid password: {e}"); + return Ok(false); + } + Ok(true) + } + )?; + cu::print!("{password}"); Ok(()) } diff --git a/packages/copper/examples/walk.rs b/packages/copper/examples/walk.rs index 1161960..e5e301b 100644 --- a/packages/copper/examples/walk.rs +++ b/packages/copper/examples/walk.rs @@ -3,7 +3,7 @@ use pistonite_cu as cu; #[cu::cli] fn main(_: cu::cli::Flags) -> cu::Result<()> { let mut src = cu::fs::walk("src")?; - cu::set_thread_print_name("walk"); + cu::cli::set_thread_name("walk"); while let Some(entry) = src.next() { let entry = entry?; cu::info!( @@ -14,7 +14,7 @@ fn main(_: cu::cli::Flags) -> cu::Result<()> { ) } - cu::set_thread_print_name("glob"); + cu::cli::set_thread_name("glob"); let glob = cu::fs::glob_from("..", "./**/*.rs")?; for entry in glob { let entry = entry?; diff --git a/packages/copper/src/cli.rs b/packages/copper/src/cli/flags.rs similarity index 51% rename from packages/copper/src/cli.rs rename to packages/copper/src/cli/flags.rs index f922150..f42096b 100644 --- a/packages/copper/src/cli.rs +++ b/packages/copper/src/cli/flags.rs @@ -1,204 +1,3 @@ -//! CLI entry point and integration with `clap` -//! -//! When `cli` feature is enabled, `clap` is re-exported from the prelude, -//! so you can use `clap` as if it's a dependency, without actually adding -//! it to your `Cargo.toml` -//! ```rust,no_run -//! # use pistonite_cu as cu; -//! use cu::pre::*; -//! use clap::Parser; -//! -//! #[derive(Parser)] -//! struct MyCli { -//! /// Just an example flag -//! #[clap(short, long)] -//! hello: bool, -//! } -//! ``` -//! -//! # Common Command Options -//! The [`Flags`] struct implement `clap::Args` to provide common -//! options that integrates with the rest of the crate: -//! - `--verbose`/`-v` to increase verbose level. -//! - `--quiet`/`-q` to decrease verbose level. -//! - `--color` to set color mode -//! -//! If your program has user interaction, the `prompt` feature enables these options: -//! - `--yes`/`-y` to answer `y` to all yes/no prompts. -//! - `--non-interactive`: Disallow prompts, prompts will fail with an error instead -//! - `--interactive`: This is the default, and cancels the effect of one `--non-interactive` -//! -//! The [`cu::cli`](macro@crate::cli) macro generates a shim -//! to parse the flags and pass it to your main function. -//! It also handles the `Result` returned back -//! ```rust,no_run -//! # use pistonite_cu as cu; -//! use cu::pre::*; -//! // clap will be part of the prelude -//! // when the `cli` feature is enabled -//! -//! // Typically, you want to have a wrapper struct -//! // so you can derive additional options with clap, -//! // and provide a description via doc comments, like below -//! -//! /// My program -//! /// -//! /// This is my program, it is very good. -//! #[derive(clap::Parser, Clone)] -//! struct Args { -//! /// Input of the program -//! #[clap(short, long)] -//! input: String, -//! /// Output of the program -//! #[clap(short, long)] -//! output: Option, -//! #[clap(flatten)] -//! inner: cu::cli::Flags, -//! } -//! // The 'flags' attribute lets the generated code access the common flags -//! // in the cli struct. When omitted, the struct should implement AsRef -//! #[cu::cli(flags = "inner")] -//! fn main(args: Args) -> cu::Result<()> { -//! cu::info!("input is {}", args.input); -//! cu::info!("output is {:?}", args.output); -//! Ok(()) -//! } -//! ``` -//! -//! If your program is simple or you don't need extra -//! description in the --help message, you can also use `cu::cli::Flags` -//! directly in `main`: -//! ```rust,no_run -//! # use pistonite_cu as cu; -//! #[cu::cli] -//! fn main(args: cu::cli::Flags) -> cu::Result<()> { -//! Ok(()) -//! } -//! ``` -//! -//! Optionally, a preprocessor function can be provided to modify the structs -//! (typically the common flags) before applying them -//! ```rust,no_run -//! # use pistonite_cu as cu; -//! use cu::pre::*; -//! -//! #[derive(clap::Parser)] -//! struct Args { -//! #[clap(subcommand)] -//! subcommand: Option, -//! #[clap(flatten)] -//! inner: cu::cli::Flags, -//! } -//! impl Args { -//! fn preprocess(&mut self) { -//! // merge subcommand flags into top level flags -//! if let Some(Command::Foo(c)) = &self.subcommand { -//! self.inner.merge(c); -//! } -//! } -//! } -//! impl AsRef for Args { -//! fn as_ref(&self) -> &cu::cli::Flags { -//! &self.inner -//! } -//! } -//! #[derive(clap::Subcommand)] -//! enum Command { -//! Foo(cu::cli::Flags), -//! } -//! #[cu::cli(preprocess = Args::preprocess)] -//! fn main(args: Args) -> cu::Result<()> { -//! Ok(()) -//! } -//! ``` -//! -//! # Printing and Logging -//! By default, even without the `cli` feature, `cu` re-exports -//! the features from `log` so you can add logging and error handling (through -//! `anyhow`) by depending on `cu` from a library. -//! -//! For crates only used in binary, but is not a binary target (i.e. -//! some shared module used for binary targets), you can enable -//! the `print` feature to get access to the `print` and `hint` macros: -//! - `print`: like `info`, but has a higher importance -//! - `hint`: like `print`, but specifically for hinting actions the user can take -//! (to resolve an error, for example). -//! -//! These 2 levels are not directly controlled by `log`, -//! and can still print when logging is statically disabled. -//! -//! The following table shows what are printed for each level, -//! (other than `print` and `hint`, the rest are re-exports from `log`) -//! | | `-qq` | ` -q` | ` ` | ` -v` | `-vv` | -//! |-|- |- |- |- |- | -//! | [`error!`](crate::error) | ❌ | ✅ | ✅ | ✅ | ✅ | -//! | [`hint!`](crate::hint) | ❌ | ✅ | ✅ | ✅ | ✅ | -//! | [`print!`](macro@crate::print) | ❌ | ✅ | ✅ | ✅ | ✅ | -//! | [`warn!`](crate::warn) | ❌ | ❌ | ✅ | ✅ | ✅ | -//! | [`info!`](crate::info) | ❌ | ❌ | ✅ | ✅ | ✅ | -//! | [`debug!`](crate::debug) | ❌ | ❌ | ❌ | ✅ | ✅ | -//! | [`trace!`](crate::trace) | ❌ | ❌ | ❌ | ❌ | ✅ | -//! -//! The `RUST_LOG` environment variable is also supported in the same -//! way as in [`env_logger`](https://docs.rs/env_logger/latest/env_logger/#enabling-logging). -//! When mixing `RUST_LOG` and verbosity flags, logging messages are filtered -//! by `RUST_LOG`, and the verbosity would only apply to `print` and `hint` -//! -//! When setting up test, you can use [`log_init`](crate::log_init) to quickly inititialize logging -//! without dealing with the details. -//! -//! [`set_thread_print_name`](crate::set_thread_print_name) can be used to add a prefix to all messages printed -//! by the current thread. -//! -//! Messages that are too long and multi-line messages are automatically wrapped. -//! -//! # Progress Bar -//! Animated progress bars are displayed at the bottom of the terminal. -//! While progress bars are visible, printing still works and will be put -//! above the bars. However, prints will be buffered and refreshed -//! and the same frame rate as the bars. -//! -//! [`progress_bar`](crate::progress_bar) and [`progress_bar_lowp`](crate::progress_bar_lowp) are used to create a bar. -//! The only difference is that `lowp` doesn't print a message when the progress -//! is done (as if the bar was never there). The bar takes a message to indicate -//! the current action, and each update call can accept a message to indicate -//! the current step. When `bar` is dropped, it will print a done message. -//! -//! ```rust,no_run -//! # use pistonite_cu as cu; -//! use std::time::Duration; -//! { -//! let bar = cu::progress_bar(10, "This takes 2.5 seconds"); -//! for i in 0..10 { -//! cu::progress!(&bar, i, "step {i}"); -//! cu::debug!("this is debug message"); -//! std::thread::sleep(Duration::from_millis(250)); -//! } -//! } -//! ``` -//! -//! [`progress_unbounded`](crate::progress_unbounded) and [`progress_unbounded_lowp`](crate::progress_unbounded_lowp) are variants -//! that doesn't display the total steps. Use `()` as the step placeholder -//! when updating the bar. -//! -//! # Prompting -//! With the `prompt` feature enabled, you can -//! use [`prompt!`](crate::prompt) and [`yesno!`](crate::yesno) to show prompts. -//! -//! The prompts are thread-safe, meaning -//! You can call them from multiple threads, and they will be queued to prompt the user one after -//! the other. Prompts are always shown regardless of verbosity. But when stdout is redirected, -//! they will not render in terminal. -//! -//! # Async Entry Point -//! For async usage, see the [`coroutine`](crate::co) concept. -//! -//! # Manual Parsing -//! [`cu::cli::try_parse`](crate::cli::try_parse) -//! and [`cu::cli::print_help`](crate::cli::print_help) can be useful -//! when you want to manually invoke a command parser. These -//! respect the `--color` option passed to the program. -//! use std::ffi::OsString; use std::time::Instant; @@ -261,28 +60,27 @@ impl Flags { match self.non_interactive.min(i8::MAX as u8) as i8 - self.interactive.min(i8::MAX as u8) as i8 { - ..0 => { + ..=0 => { if self.yes { - Some(lv::Prompt::Yes) + Some(lv::Prompt::YesOrInteractive) } else { Some(lv::Prompt::Interactive) } } - 0 => { + _ => { if self.yes { - Some(lv::Prompt::Yes) + Some(lv::Prompt::YesOrBlock) } else { - None + Some(lv::Prompt::Block) } } - _ => Some(lv::Prompt::No), } #[cfg(not(feature = "prompt"))] { None } }; - crate::init_print_options(self.color.unwrap_or_default(), level, prompt); + super::print_init::init_options(self.color.unwrap_or_default(), level, prompt); } /// Merge `other` into self. Options in other will be applied on top of self (equivalent @@ -348,6 +146,9 @@ pub unsafe fn __co_run< ) -> std::process::ExitCode { let start = std::time::Instant::now(); let args = unsafe { parse_args_or_help::(fn_preproc, fn_flag) }; + #[cfg(not(feature = "coroutine-heavy"))] + let result = crate::co::block(async move { fn_execute(args).await }); + #[cfg(feature = "coroutine-heavy")] let result = crate::co::run(async move { fn_execute(args).await }); handle_result(start, result) @@ -397,7 +198,7 @@ pub fn try_parse(iter: I) -> Option where I::Item: Into + Clone, { - let use_color = crate::color_enabled(); + let use_color = crate::lv::color_enabled(); let result = get_colored_command::(use_color) .try_get_matches_from(iter) .and_then(|mut matches| ::from_arg_matches_mut(&mut matches)); @@ -421,7 +222,7 @@ where /// an application. #[inline(always)] pub fn print_help(long: bool) { - let command = get_colored_command::(crate::color_enabled()); + let command = get_colored_command::(crate::lv::color_enabled()); print_help_impl(command, long) } fn print_help_impl(mut command: Command, long: bool) { @@ -464,7 +265,14 @@ fn handle_result(start: Instant, result: crate::Result<()>) -> std::process::Exi let elapsed = start.elapsed().as_secs_f32(); if let Err(e) = result { crate::error!("fatal: {e:?}"); - if crate::lv::is_trace_hint_enabled() { + // we display the hint for user to use -vv + // if: + // - the user is already tried to get more debug info with -v + // (because otherwise it will be too noisy and it might be a user-error, + // not a bug + // - the trace hint is not explicitly disabled + // - the trace hint is not already displayed + if lv::D.enabled() && crate::lv::is_trace_hint_enabled() { if std::env::var("RUST_BACKTRACE") .unwrap_or_default() .is_empty() diff --git a/packages/copper/src/print/ansi.rs b/packages/copper/src/cli/fmt/ansi.rs similarity index 95% rename from packages/copper/src/print/ansi.rs rename to packages/copper/src/cli/fmt/ansi.rs index c72e153..a8e113f 100644 --- a/packages/copper/src/print/ansi.rs +++ b/packages/copper/src/cli/fmt/ansi.rs @@ -73,7 +73,7 @@ impl<'a> Iterator for AnsiWidthIter<'a> { fn next(&mut self) -> Option { let c = self.chars.next()?; let width = if self.is_escaping { - if is_ansi_end_char(c) { + if is_esc_end(c) { self.is_escaping = false; } 0 @@ -89,7 +89,7 @@ impl<'a> Iterator for AnsiWidthIter<'a> { } } -pub(crate) fn is_ansi_end_char(c: char) -> bool { +pub(crate) fn is_esc_end(c: char) -> bool { // we only do very basic check right now c < u8::MAX as char && b"mAKGJBCDEFHSTfhlin".contains(&(c as u8)) } diff --git a/packages/copper/src/print/format.rs b/packages/copper/src/cli/fmt/format_buffer.rs similarity index 65% rename from packages/copper/src/print/format.rs rename to packages/copper/src/cli/fmt/format_buffer.rs index 9dbf480..29e9d8c 100644 --- a/packages/copper/src/print/format.rs +++ b/packages/copper/src/cli/fmt/format_buffer.rs @@ -1,64 +1,63 @@ -use super::ansi; - -/// Get the terminal width, or the internal max if cannot get -pub fn term_width_or_max() -> usize { - term_width().unwrap_or(400) -} - -/// Get the terminal width, capped as some internal amount -pub fn term_width() -> Option { - term_width_height().map(|x| x.0) -} - -/// Get the terminal height, capped as some internal amount -pub fn term_width_height() -> Option<(usize, usize)> { - use terminal_size::*; - terminal_size().map(|(Width(w), Height(h))| ((w as usize).min(400), (h as usize).min(400))) -} +use crate::cli::fmt::{self, ansi}; +/// Buffer for formatting printing messages pub(crate) struct FormatBuffer { + /// Total width to print width: usize, + /// Current char position in the line curr: usize, + /// Internal buffer buffer: String, + /// ANSI code for gray gray_color: &'static str, + /// ANSI code for the current text color text_color: &'static str, } impl FormatBuffer { pub fn new() -> Self { Self { - width: term_width_or_max(), + width: fmt::term_width_or_max(), curr: 0, buffer: String::new(), gray_color: "", text_color: "", } } + /// Get the formatted buffer content pub fn as_str(&self) -> &str { self.buffer.as_str() } + /// Take the formatted buffer content out, leaving empty string + #[allow(unused)] // prompt uses it pub fn take(&mut self) -> String { std::mem::take(&mut self.buffer) } + /// Reset pub fn reset(&mut self, gray_color: &'static str, text_color: &'static str) { self.curr = 0; self.buffer.clear(); - self.width = term_width_or_max(); + self.width = fmt::term_width_or_max(); self.gray_color = gray_color; self.text_color = text_color; } - pub fn end(&mut self) { + + /// Push a newline character (note this is different from [`new_line`](Self::new_line)) + pub fn push_lf(&mut self) { self.buffer.push('\n'); } - + /// Push a string as control characters. i.e. the content will be + /// appended to the buffer without formatting. + pub fn push_control(&mut self, x: &str) { + self.buffer.push_str(x) + } + /// Push and format string content pub fn push_str(&mut self, x: &str) { for (c, w) in ansi::with_width(x.chars()) { self.push(c, w); } } - pub fn push_control(&mut self, x: &str) { - self.buffer.push_str(x) - } + /// Push a character with its width pub fn push(&mut self, c: char, w: usize) { if c == '\n' { self.new_line(); @@ -75,7 +74,7 @@ impl FormatBuffer { self.buffer.push(c); self.curr += w; } - + /// Start formatting a new line pub fn new_line(&mut self) { self.buffer.push('\n'); self.buffer.push_str(self.gray_color); diff --git a/packages/copper/src/cli/fmt/mod.rs b/packages/copper/src/cli/fmt/mod.rs new file mode 100644 index 0000000..44e92c7 --- /dev/null +++ b/packages/copper/src/cli/fmt/mod.rs @@ -0,0 +1,7 @@ +pub mod ansi; +pub mod utf8; + +mod term_size; +pub use term_size::*; +mod format_buffer; +pub(crate) use format_buffer::*; diff --git a/packages/copper/src/cli/fmt/term_size.rs b/packages/copper/src/cli/fmt/term_size.rs new file mode 100644 index 0000000..5b6998d --- /dev/null +++ b/packages/copper/src/cli/fmt/term_size.rs @@ -0,0 +1,20 @@ +/// Get the terminal width, or the internal max if cannot get +pub fn term_width_or_max() -> usize { + term_width().unwrap_or(400) +} + +/// Get the terminal width, capped as some internal amount +pub fn term_width() -> Option { + term_width_height().map(|x| x.0) +} + +/// Get the terminal height, capped as some internal amount +pub fn term_width_height() -> Option<(usize, usize)> { + if cfg!(feature = "__test") { + // fix the size in test + Some((60, 20)) + } else { + use terminal_size::*; + terminal_size().map(|(Width(w), Height(h))| ((w as usize).min(400), (h as usize).min(400))) + } +} diff --git a/packages/copper/src/print/utf8.rs b/packages/copper/src/cli/fmt/utf8.rs similarity index 100% rename from packages/copper/src/print/utf8.rs rename to packages/copper/src/cli/fmt/utf8.rs diff --git a/packages/copper/src/cli/macros.rs b/packages/copper/src/cli/macros.rs new file mode 100644 index 0000000..4001d71 --- /dev/null +++ b/packages/copper/src/cli/macros.rs @@ -0,0 +1,246 @@ +use cu::cli::printer::PRINTER; +use cu::lv; +/// Print something +/// +/// This is similar to `info`, but unlike info, this message will still log with `-q`. +#[macro_export] +#[cfg(feature = "print")] +macro_rules! print { + ($($fmt_args:tt)*) => {{ + $crate::cli::__print_with_level($crate::lv::P, format_args!($($fmt_args)*)); + }} +} +/// Logs a hint message +#[macro_export] +#[cfg(feature = "print")] +macro_rules! hint { + ($($fmt_args:tt)*) => {{ + $crate::cli::__print_with_level($crate::lv::H, format_args!($($fmt_args)*)); + }} +} + +/// # Prompting +/// The `prompt` feature allows displaying prompts in the console to accept +/// user input. The prompts are thread-safe and synchronized with the printer. +/// When a prompt is active, outputs to the console will be buffered inside the printer. +/// Progress bars will also be paused. +/// +/// Prompts are driven by macros where you can format a prompt message +/// ```rust,no_run +/// # use pistonite_cu as cu; +/// let name = cu::prompt!("please enter your name")?; +/// cu::info!("user entered: {name}"); +/// # cu::Ok(()) +/// ``` +/// +/// See other macros for advanced usage: +/// - [`cu::yesno!`](macro@crate::yesno): Display a `[y/n]` prompt which loops +/// until user chooses yes or no. +/// - [`cu::prompt_password!`](macro@crate::prompt_password): +/// Display a prompt where the input will be hidden +/// - [`cu::prompt_validate!`](macro@crate::prompt_validate) (and +/// [`prompt_password_validate!`](macro@crate::prompt_password_validate)) to loop the prompt until a validation function passes. +/// +/// With the `prompt` feature enabled, you can +/// use [`prompt!`](crate::prompt) and [`yesno!`](crate::yesno) to show prompts. +/// +/// The prompts are thread-safe, meaning +/// You can call them from multiple threads, and they will be queued to prompt the user one after +/// the other. Prompts are always shown regardless of verbosity. But when stdout is redirected, +/// they will not render in terminal. +/// Show a prompt +/// +/// Use the `prompt-password` feature and [`prompt_password!`](crate::prompt_password) macro +/// if prompting for a password, which will hide user's input from the console +/// +#[cfg(feature = "prompt")] +#[macro_export] +macro_rules! prompt { + ($($fmt_args:tt)*) => {{ + $crate::cli::__prompt(format_args!($($fmt_args)*), false).map(|x| x.to_string()) + }} +} + +/// Show a Yes/No prompt +/// +/// Return `true` if the answer is Yes. Return an error if prompt is not allowed. +/// +/// If `-y` is specified from the command line, then the prompt will not show, +/// and `true` will be returned immediately. +/// +/// If user does not answer `y` or `n`, the prompt will show again, until +/// user makes a decision. +/// ```rust,no_run +/// # use pistonite_cu as cu; +/// if cu::yesno!("do you want to continue?")? { +/// cu::info!("user picked yes"); +/// } +/// # cu::Ok(()) +/// ``` +#[cfg(feature = "prompt")] +#[macro_export] +macro_rules! yesno { + ($($fmt_args:tt)*) => {{ + $crate::cli::__prompt_yesno(format_args!($($fmt_args)*)) + }} +} + +/// Show a password prompt +/// +/// The console will have inputs hidden while user types, and the returned +/// value is a [`cu::ZString`](struct@crate::ZString) +/// +/// ```rust,no_run +/// # use pistonite_cu as cu; +/// let password = cu::prompt_password!("please enter your password")?; +/// cu::info!("user entered: {password}"); +/// # cu::Ok(()) +/// ``` +#[cfg(feature = "prompt-password")] +#[macro_export] +macro_rules! prompt_password { + ($($fmt_args:tt)*) => {{ + $crate::cli::__prompt(format_args!($($fmt_args)*), true) + }} +} + +/// Loop a prompt until a validation function passes +/// +/// The validation function takes a `&mut String`, +/// and returns `cu::Result`, where: +/// - `Ok(true)` means the validation passed. +/// - `Ok(false)` means the validation failed. The function can optionally +/// print some kind of error or hint message +/// - `Err` means there is an error, the error will be propagated to the prompt call. +/// +/// ```rust,no_run +/// # use pistonite_cu as cu; +/// // note that extra parenthesis is needed if the format args +/// // are not inlined into the formatting literal +/// let expected = "rust"; +/// let answer = cu::prompt_validate!( +/// ("what's your favorite programming language? please answer {}", expected), +/// |answer| { +/// if answer == expected { +/// return Ok(true); +/// } +/// if answer == "javascript" { +/// cu::bail!("that's not good"); +/// } +/// cu::error!("try again"); +/// Ok(false) +/// } +/// )?; +/// assert!(answer == expected); +/// # cu::Ok(()) +/// ``` +/// +/// The validation function can be a `FnMut` closure, which means +/// it can double as a result parsing function if needed +/// +/// ```rust,no_run +/// # use pistonite_cu as cu; +/// let mut index: i32 = 0; +/// cu::prompt_validate!( +/// "select a number between 0 and 5", +/// |answer| { +/// let number = match cu::parse::(answer) { +/// Err(e) => { +/// cu::error!("{e}"); +/// cu::hint!("please ensure you are entering a number"); +/// return Ok(false); +/// } +/// Ok(x) => x +/// }; +/// if number < 0 { +/// cu::error!("the number you entered is too small"); +/// return Ok(false); +/// } +/// if number > 5 { +/// cu::error!("the number you entered is too big"); +/// return Ok(false); +/// } +/// index = number; +/// Ok(true) +/// } +/// )?; +/// cu::info!("index is {index}"); +/// # cu::Ok(()) +/// ``` +/// +/// For the password version, see [`prompt_password_validate`](crate::prompt_password_validate) +/// +#[cfg(feature = "prompt")] +#[macro_export] +macro_rules! prompt_validate { + ($l:literal, $validator:expr) => {{ + $crate::cli::__prompt_with_validation(format_args!($l), false, $validator) + .map(|x| x.to_string()) + }}; + (($($fmt_args:tt)*), $validator:expr) => {{ + $crate::cli::__prompt_with_validation(format_args!($($fmt_args)*), false, $validator) + .map(|x| x.to_string()) + }} +} + +/// Loop a password prompt until a validation function passes +/// +/// The validation function takes a `&mut String`, +/// and returns `cu::Result`, where: +/// - `Ok(true)` means the validation passed. +/// - `Ok(false)` means the validation failed. The function can optionally +/// print some kind of error or hint message +/// - `Err` means there is an error, the error will be propagated to the prompt call. +/// +/// ```rust,no_run +/// # use pistonite_cu as cu; +/// // note that extra parenthesis is needed if the format args +/// // are not inlined into the formatting literal +/// let password = cu::prompt_password_validate!( +/// "please enter a password between 8 and 16 charactres and only contain sensible characters", +/// |answer| { +/// if answer == "123456" { +/// cu::bail!("how can you do that, bye"); +/// } +/// if answer.len() < 8 { +/// cu::error!("password is too short"); +/// return Ok(false); +/// } +/// if answer.len() > 16 { +/// cu::error!("password is too long"); +/// return Ok(false); +/// } +/// if let Err(e) = cu::password_chars_legal(answer) { +/// cu::error!("invalid password: {e}"); +/// return Ok(false); +/// } +/// Ok(true) +/// } +/// )?; +/// cu::print!("{password}"); +/// # cu::Ok(()) +/// ``` +#[cfg(feature = "prompt-password")] +#[macro_export] +macro_rules! prompt_password_validate { + ($l:literal, $validator:expr) => {{ + $crate::cli::__prompt_with_validation(format_args!($l), true, $validator) + }}; + (($($fmt_args:tt)*), $validator:expr) => {{ + $crate::cli::__prompt_with_validation(format_args!($($fmt_args)*), true, $validator) + }} +} + +/// Internal print function for macros +#[doc(hidden)] +pub fn __print_with_level(lv: lv::Lv, message: std::fmt::Arguments<'_>) { + if !lv.can_print(lv::PRINT_LEVEL.get()) { + return; + } + let message = format!("{message}"); + if let Ok(mut printer) = PRINTER.lock() { + if let Some(printer) = printer.as_mut() { + printer.print_message(lv, &message); + } + } +} diff --git a/packages/copper/src/cli/mod.rs b/packages/copper/src/cli/mod.rs new file mode 100644 index 0000000..575cb54 --- /dev/null +++ b/packages/copper/src/cli/mod.rs @@ -0,0 +1,169 @@ +//! # Printting and Command Line Interface +//! +//! There are 4 feature flags related to CLI +//! - `print`: This is the most minimal feature set. Using +//! features from this feature flag means you acknowledge your code +//! is being called from a program that uses `cu::cli` (i.e. the `cli` feature) +//! - `cli`: Use this if your crate is the end binary (i.e. not a library). +//! This integrates and re-exports [`clap`](https://docs.rs/clap). +//! - This turns on `print` automatically +//! - `prompt`: This implies `print` and will also enable the ability to show prompts in the terminal. +//! - `prompt-password`: This implies `prompt` (which implies `print`), and allows prompting for +//! password (which hides the input when user types into the terminal) +//! +//! # Integration with `clap` +//! +//! When the `cli` feature is enabled, `clap` is re-exported from the prelude, +//! so you can use `clap` as if it's a dependency, without actually adding +//! it to your `Cargo.toml` +//! ```rust,no_run +//! # use pistonite_cu as cu; +//! use cu::pre::*; +//! use clap::Parser; +//! +//! #[derive(Parser)] +//! struct MyCli { +//! /// Just an example flag +//! #[clap(short, long)] +//! hello: bool, +//! } +//! ``` +//! +//! # Common Command Options +//! The [`Flags`] struct implement `clap::Args` to provide common +//! options that integrates with the rest of the crate: +//! - `--verbose`/`-v` to increase verbose level. +//! - `--quiet`/`-q` to decrease verbose level. +//! - `--color` to set color mode +//! +//! The `prompt` feature enables these additional options: +//! - `--yes`/`-y` to answer `y` to all yes/no prompts. +//! - `--non-interactive`: Disallow prompts, prompts will fail with an error instead +//! - With `--yes --non-interactive`, yes/no prompts gets answered `yes` and other prompts are +//! blocked +//! - `--interactive`: This is the default, and cancels the effect of one `--non-interactive` +//! +//! The [`cu::cli`](macro@crate::cli) macro generates a shim +//! to parse the flags and pass it to your main function. +//! It also handles the `Result` returned back. See the example +//! below and more usage examples in the documentation for the macro. +//! ```rust,no_run +//! # use pistonite_cu as cu; +//! use cu::pre::*; +//! // clap will be part of the prelude +//! // when the `cli` feature is enabled +//! +//! // Typically, you want to have a wrapper struct +//! // so you can derive additional options with clap, +//! // and provide a description via doc comments, like below +//! +//! // clap will parse the doc comment of the Args struct +//! // as the help text +//! +//! /// My program +//! /// +//! /// This is my program, it is very good. +//! #[derive(clap::Parser, Clone)] +//! struct Args { +//! /// Input of the program +//! #[clap(short, long)] +//! input: String, +//! /// Output of the program +//! #[clap(short, long)] +//! output: Option, +//! #[clap(flatten)] +//! inner: cu::cli::Flags, +//! } +//! // use the flags attribute to refer to the cu::cli::Flags field inside the Args struct +//! #[cu::cli(flags = "inner")] +//! fn main(args: Args) -> cu::Result<()> { +//! cu::info!("input is {}", args.input); +//! cu::info!("output is {:?}", args.output); +//! Ok(()) +//! } +//! ``` +//! +//! # Printing and Logging +//! In addition to the logging macros re-exported from the [`log`](https://docs.rs/log) +//! crate, `cu` provides `print` and `hint` macros: +//! - `print`: like `info`, but has a higher importance +//! - `hint`: like `print`, but specifically for hinting actions the user can take +//! (to resolve an error, for example). +//! +//! These 2 levels are not directly controlled by `log`, +//! and can still print when logging is statically disabled. +//! +//! The following table shows what are printed for each level, +//! | | `-qq` | ` -q` | ` ` | ` -v` | `-vv` | +//! |-|- |- |- |- |- | +//! | [`error!`](crate::error) | ❌ | ✅ | ✅ | ✅ | ✅ | +//! | [`hint!`](crate::hint) | ❌ | ✅ | ✅ | ✅ | ✅ | +//! | [`print!`](macro@crate::print) | ❌ | ✅ | ✅ | ✅ | ✅ | +//! | [`warn!`](crate::warn) | ❌ | ❌ | ✅ | ✅ | ✅ | +//! | [`info!`](crate::info) | ❌ | ❌ | ✅ | ✅ | ✅ | +//! | [`debug!`](crate::debug) | ❌ | ❌ | ❌ | ✅ | ✅ | +//! | [`trace!`](crate::trace) | ❌ | ❌ | ❌ | ❌ | ✅ | +//! +//! The `RUST_LOG` environment variable is also supported in the same +//! way as in [`env_logger`](https://docs.rs/env_logger/latest/env_logger/#enabling-logging). +//! When mixing `RUST_LOG` and verbosity flags, logging messages are filtered +//! by `RUST_LOG`, and the verbosity would only apply to `print` and `hint` +//! +//! # Other +//! When setting up test, you can use [`cu::cli::level`] to quickly inititialize logging +//! without dealing with the details. +//! +//! [`cu::cli::set_thread_name`] can be used to add a prefix to all messages printed +//! by the current thread. +//! +//! Messages that are too long and multi-line messages are automatically wrapped. +//! +//! # Manual Parsing CLI args +//! [`cu::cli::try_parse`](crate::cli::try_parse) +//! and [`cu::cli::print_help`](crate::cli::print_help) can be useful +//! when you want to manually invoke a command parser. These +//! respect the `--color` option passed to the program. +//! +//! # Progress Bars +//! See [Progress Bars](fn@crate::progress) +//! +//! # Prompting +//! See [Prompting](macro@crate::prompt) +//! +#[cfg(feature = "cli")] +mod flags; +#[cfg(all(feature = "coroutine", feature = "cli"))] +pub use flags::__co_run; +#[cfg(feature = "cli")] +pub use flags::{__run, Flags, print_help, try_parse}; + +mod print_init; +pub use print_init::level; +mod macros; +pub use macros::__print_with_level; + +mod thread_name; +use thread_name::THREAD_NAME; +pub use thread_name::set_thread_name; +mod printer; + +mod progress; +pub use progress::{ProgressBar, ProgressBarBuilder, progress}; + +#[cfg(feature = "prompt")] +mod prompt; +#[cfg(feature = "prompt")] +pub use prompt::{__prompt, __prompt_with_validation, __prompt_yesno}; +#[cfg(feature = "prompt-password")] +mod password; +#[cfg(feature = "prompt-password")] +pub use password::password_chars_legal; + +/// Formatting utils +pub(crate) mod fmt; + +// 50ms between each cycle +const TICK_INTERVAL: std::time::Duration = std::time::Duration::from_millis(10); +// 2B ticks * 10ms = 251 days. +// overflown tick means ETA will be inaccurate (after 251 days) +type Tick = u32; diff --git a/packages/copper/src/print/prompt_password.rs b/packages/copper/src/cli/password.rs similarity index 86% rename from packages/copper/src/print/prompt_password.rs rename to packages/copper/src/cli/password.rs index 277fe39..6eeb2d2 100644 --- a/packages/copper/src/print/prompt_password.rs +++ b/packages/copper/src/cli/password.rs @@ -8,12 +8,12 @@ // SPDX-License-Identifier: Apache-2.0 // Copyright (c) https://github.com/conradkleinespel/rpassword -#[cfg(target_family = "unix")] +#[cfg(unix)] pub(crate) use unix::read_password; -#[cfg(target_family = "windows")] +#[cfg(windows)] pub(crate) use windows::read_password; -#[cfg(target_family = "unix")] +#[cfg(unix)] mod unix { use libc::{ECHO, ECHONL, TCSANOW, c_int, tcsetattr, termios}; use std::io::{self, BufRead}; @@ -56,21 +56,21 @@ mod unix { } /// Turns a C function return into an IO Result - fn io_result(ret: c_int) -> std::io::Result<()> { + fn io_result(ret: c_int) -> io::Result<()> { match ret { 0 => Ok(()), - _ => Err(std::io::Error::last_os_error()), + _ => Err(io::Error::last_os_error()), } } - fn safe_tcgetattr(fd: c_int) -> std::io::Result { + fn safe_tcgetattr(fd: c_int) -> io::Result { let mut term = mem::MaybeUninit::::uninit(); io_result(unsafe { ::libc::tcgetattr(fd, term.as_mut_ptr()) })?; Ok(unsafe { term.assume_init() }) } /// Reads a password from the TTY - pub(crate) fn read_password() -> std::io::Result { + pub(crate) fn read_password() -> io::Result { let tty = std::fs::File::open("/dev/tty")?; let fd = tty.as_raw_fd(); let mut reader = io::BufReader::new(tty); @@ -82,8 +82,8 @@ mod unix { fn read_password_from_fd_with_hidden_input( reader: &mut impl BufRead, fd: i32, - ) -> std::io::Result { - let mut password = crate::ZeroWhenDropString::default(); + ) -> io::Result { + let mut password = crate::ZString::default(); { let _hidden_input = HiddenInput::new(fd)?; reader.read_line(&mut password)?; @@ -92,7 +92,7 @@ mod unix { } } -#[cfg(target_family = "windows")] +#[cfg(windows)] mod windows { use std::io::{self, BufRead, BufReader}; use std::os::windows::io::FromRawHandle; @@ -118,13 +118,13 @@ mod windows { // Get the old mode so we can reset back to it when we are done if unsafe { GetConsoleMode(handle, &mut mode as *mut CONSOLE_MODE) } == 0 { - return Err(std::io::Error::last_os_error()); + return Err(io::Error::last_os_error()); } // We want to be able to read line by line, and we still want backspace to work let new_mode_flags = ENABLE_LINE_INPUT | ENABLE_PROCESSED_INPUT; if unsafe { SetConsoleMode(handle, new_mode_flags) } == 0 { - return Err(std::io::Error::last_os_error()); + return Err(io::Error::last_os_error()); } Ok(HiddenInput { mode, handle }) @@ -141,7 +141,7 @@ mod windows { } /// Reads a password from the TTY - pub fn read_password() -> std::io::Result { + pub fn read_password() -> io::Result { let handle = unsafe { CreateFileA( c"CONIN$".as_ptr() as PCSTR, @@ -155,7 +155,7 @@ mod windows { }; if handle == INVALID_HANDLE_VALUE { - return Err(std::io::Error::last_os_error()); + return Err(io::Error::last_os_error()); } let mut stream = BufReader::new(unsafe { std::fs::File::from_raw_handle(handle as _) }); @@ -166,8 +166,8 @@ mod windows { fn read_password_from_handle_with_hidden_input( reader: &mut impl BufRead, handle: HANDLE, - ) -> io::Result { - let mut password = crate::ZeroWhenDropString::default(); + ) -> io::Result { + let mut password = crate::ZString::default(); { let _hidden_input = HiddenInput::new(handle)?; reader.read_line(&mut password)?; @@ -189,7 +189,8 @@ macro_rules! special_chars { } } special_chars! { '!' | '#' | '$' | '%' | '&' | '(' | ')' | '*' | '+' | ',' | '-' | '.' | '/' | ':' | ';' | '<' | '=' | '>' | '?' | '@' | '[' | ']' | '^' | '_' | '`' | '{' | '|' | '}' | '~'} -pub fn check_password_legality(s: &str) -> crate::Result<()> { +/// Check if the password contains all "legal" characters (and is non-empty) +pub fn password_chars_legal(s: &str) -> crate::Result<()> { if s.is_empty() { crate::bail!("password cannot be empty"); } diff --git a/packages/copper/src/cli/print_init.rs b/packages/copper/src/cli/print_init.rs new file mode 100644 index 0000000..cfd87ec --- /dev/null +++ b/packages/copper/src/cli/print_init.rs @@ -0,0 +1,143 @@ +use std::sync::OnceLock; +use std::sync::atomic::Ordering; + +use cu::cli::printer::{PRINTER, Printer}; +#[cfg(feature = "prompt")] +use cu::cli::prompt::PROMPT_LEVEL; +use cu::lv; +use env_filter::{Builder as LogEnvBuilder, Filter as LogEnvFilter}; + +static LOG_FILTER: OnceLock = OnceLock::new(); +/// Set the global log filter +pub(crate) fn set_log_filter(filter: LogEnvFilter) { + let _ = LOG_FILTER.set(filter); +} + +/// Shorthand to quickly setup logging. Can be useful in tests. +/// +/// `qq`, `q`, `v` and `vv` inputs map to corresponding print levels. Other inputs +/// are mapped to default level +#[doc(alias = "quick_init")] +pub fn level(lv: &str) { + let level = match lv { + "qq" => lv::Print::QuietQuiet, + "q" => lv::Print::Quiet, + "v" => lv::Print::Verbose, + "vv" => lv::Print::VerboseVerbose, + _ => lv::Print::Normal, + }; + init_options(lv::Color::Auto, level, Some(lv::Prompt::Block)); +} + +/// Set global print options. This is usually called from clap args +/// +/// If prompt option is `None`, it will be `Interactive` unless env var `CI` is `true` or `1`, in which case it becomes `No`. +/// Prompt option is ignored unless `prompt` feature is enabled +pub fn init_options(color: lv::Color, level: lv::Print, prompt: Option) { + // not using cu::env_var, since we are before log initialization + let env_rust_log = std::env::var("RUST_LOG"); + let log_level = match env_rust_log { + Ok(value) if !value.is_empty() => { + let mut builder = LogEnvBuilder::new(); + let filter = builder.parse(&value).build(); + let log_level = filter.filter(); + set_log_filter(filter); + log_level.max(level.into()) + } + _ => level.into(), + }; + log::set_max_level(log_level); + + let use_color = color.is_colored_for_stdout(); + lv::USE_COLOR.store(use_color, Ordering::Release); + let printer = Printer::new(use_color); + if let Ok(mut g_printer) = PRINTER.lock() { + *g_printer = Some(printer); + } + #[cfg(feature = "prompt")] + { + let prompt = match prompt { + Some(x) => x, + None => { + let is_ci = std::env::var("CI") + .map(|mut x| { + x.make_ascii_lowercase(); + matches!(x.trim(), "true" | "1") + }) + .unwrap_or_default(); + if is_ci { + lv::Prompt::Block + } else { + lv::Prompt::Interactive + } + } + }; + PROMPT_LEVEL.set(prompt) + } + #[cfg(not(feature = "prompt"))] + { + let _ = prompt; + } + + lv::PRINT_LEVEL.set(level); + let _ = log::set_logger(&LogImpl); +} +struct LogImpl; +impl log::Log for LogImpl { + fn enabled(&self, metadata: &log::Metadata) -> bool { + match LOG_FILTER.get() { + Some(filter) => filter.enabled(metadata), + None => lv::Lv::from(metadata.level()).can_print(lv::PRINT_LEVEL.get()), + } + } + + fn log(&self, record: &log::Record) { + if !self.enabled(record.metadata()) { + return; + } + let typ: lv::Lv = record.level().into(); + let message = if typ == lv::T { + // enable source location logging in trace messages + let mut message = String::new(); + message.push('['); + if let Some(p) = record.module_path() { + // aliased crate, use the shorthand + if let Some(rest) = p.strip_prefix("pistonite_") { + message.push_str(rest); + } else { + message.push_str(p); + } + message.push(' '); + } + if let Some(f) = record.file() { + let name = match f.rfind(['/', '\\']) { + None => f, + Some(i) => &f[i + 1..], + }; + message.push_str(name); + } + if let Some(l) = record.line() { + message.push(':'); + message.push_str(&format!("{l}")); + } + if message.len() > 1 { + message += "] "; + } else { + message.clear(); + } + + use std::fmt::Write; + let _: Result<_, _> = write!(&mut message, "{}", record.args()); + message + } else { + record.args().to_string() + }; + if let Ok(mut printer) = PRINTER.lock() { + if let Some(printer) = printer.as_mut() { + printer.print_message(typ, &message); + } + } + } + + fn flush(&self) {} +} diff --git a/packages/copper/src/print/printer.rs b/packages/copper/src/cli/printer.rs similarity index 55% rename from packages/copper/src/print/printer.rs rename to packages/copper/src/cli/printer.rs index 8343913..7705a97 100644 --- a/packages/copper/src/print/printer.rs +++ b/packages/copper/src/cli/printer.rs @@ -1,63 +1,36 @@ use std::collections::VecDeque; -use std::sync::{Arc, LazyLock, Mutex, Weak}; +use std::io::{self, IsTerminal as _}; +use std::ops::ControlFlow; +use std::sync::{Arc, Mutex, Weak}; use std::thread::JoinHandle; -use std::time::Duration; -use super::{FormatBuffer, ProgressBar, ansi}; +#[cfg(feature = "prompt")] +use oneshot::Receiver as OnceRecv; +use oneshot::Sender as OnceSend; -use crate::{ZeroWhenDropString, lv}; - -/// Print something -/// -/// This is similar to `info`, but unlike info, this message will still log with `-q`. -#[macro_export] -macro_rules! print { - ($($fmt_args:tt)*) => {{ - $crate::__priv::__print_with_level($crate::lv::P, format_args!($($fmt_args)*)); - }} -} -/// Logs a hint message -#[macro_export] -macro_rules! hint { - ($($fmt_args:tt)*) => {{ - $crate::__priv::__print_with_level($crate::lv::H, format_args!($($fmt_args)*)); - }} -} - -/// Internal print function for macros -pub fn __print_with_level(lv: lv::Lv, message: std::fmt::Arguments<'_>) { - if !lv.can_print(lv::PRINT_LEVEL.get()) { - return; - } - let message = format!("{message}"); - if let Ok(mut printer) = PRINTER.lock() { - printer.print_message(lv, &message); - } -} - -pub(crate) static PRINTER: LazyLock> = - LazyLock::new(|| Mutex::new(Printer::default())); +use crate::cli::fmt::{self, FormatBuffer, ansi}; +#[cfg(feature = "prompt-password")] +use crate::cli::password; +use crate::cli::progress::{BarFormatter, BarResult, ProgressBar}; +use crate::cli::{THREAD_NAME, TICK_INTERVAL, Tick}; +use crate::lv; /// Global printer state +pub(crate) static PRINTER: Mutex> = Mutex::new(None); pub(crate) struct Printer { + is_stdin_terminal: bool, /// Handle to stdout - stdout: std::io::Stdout, + stdout: io::Stdout, /// Handle to stderr - stderr: std::io::Stderr, + stderr: io::Stderr, /// Color codes colors: ansi::Colors, /// Control codes controls: ansi::Controls, - // printing - /// Handle for the printing task, None means - /// either no printing task is running, or, the printing - /// task is terminating - print_task: PrintThread, + print_task: PrintingThread, bar_target: Option, bars: Vec>, - - prompt_task: PrintThread, pending_prompts: VecDeque, /// Buffer for automatically do certain formatting @@ -65,57 +38,49 @@ pub(crate) struct Printer { /// Place to buffer prints while printing is blocked buffered: String, } - -struct PromptTask { - send: oneshot::Sender>, - prompt: String, - #[cfg(feature = "prompt-password")] - is_password: bool, -} - -impl Default for Printer { - fn default() -> Self { - use std::io::IsTerminal as _; - let stdout = std::io::stdout(); - let stderr = std::io::stderr(); - let is_terminal = stdout.is_terminal(); - let bar_target = if is_terminal { - Some(Target::Stdout) - } else if stderr.is_terminal() { - Some(Target::Stderr) +impl Printer { + pub fn new(use_color: bool) -> Self { + let colors = ansi::colors(use_color); + let stdout = io::stdout(); + let stderr = io::stderr(); + let is_stdin_terminal = io::stdin().is_terminal(); + let (bar_target, is_terminal) = if cfg!(feature = "__test") { + (Some(Target::Stdout), true) } else { - None + let is_terminal = stdout.is_terminal(); + let bar_target = if is_terminal { + Some(Target::Stdout) + } else if stderr.is_terminal() { + Some(Target::Stderr) + } else { + None + }; + (bar_target, is_terminal) }; - let colors = ansi::colors(is_terminal); let controls = ansi::controls(is_terminal); Self { + is_stdin_terminal, stdout, stderr, colors, controls, + print_task: Default::default(), bar_target, bars: Default::default(), - - prompt_task: Default::default(), pending_prompts: Default::default(), format_buffer: FormatBuffer::new(), buffered: String::new(), } } -} -impl Printer { - pub(crate) fn set_colors(&mut self, use_color: bool) { - self.colors = ansi::colors(use_color); - } - + #[cfg(feature = "prompt")] pub(crate) fn show_prompt( &mut self, prompt: &str, is_password: bool, - ) -> oneshot::Receiver> { + ) -> OnceRecv> { // format the prompt let mut lines = prompt.lines(); self.format_buffer.reset(self.colors.gray, self.colors.cyan); @@ -133,31 +98,12 @@ impl Printer { if cfg!(feature = "prompt-password") && is_password { self.format_buffer.push_str(": "); } else { - self.format_buffer.end(); + self.format_buffer.push_lf(); self.format_buffer.push_control(self.colors.reset); self.format_buffer.push_control("-: "); } - // show the prompt let (send, recv) = oneshot::channel(); - if !self.prompt_task.active() { - self.prompt_task.join(); - // erase current line, and print new prompt - // this may mess up progress bars - having both prompts - // and progress bar is not a good idea anyway - use std::io::Write; - let _ = write!( - self.stdout, - "{}{}{}", - self.controls.move_to_begin_and_clear, - self.buffered, - self.format_buffer.as_str() - ); - self.buffered.clear(); - let _ = self.stdout.flush(); - self.prompt_task.assign(prompt_task(send, is_password)); - return recv; - } #[cfg(feature = "prompt-password")] { self.pending_prompts.push_back(PromptTask { @@ -173,6 +119,7 @@ impl Printer { prompt: self.format_buffer.take(), }); } + self.start_print_task_if_needed(); recv } @@ -186,19 +133,46 @@ impl Printer { } // start the bar self.bars.push(Arc::downgrade(bar)); + self.start_print_task_if_needed(); + } + + fn start_print_task_if_needed(&mut self) { if !self.print_task.active() { self.print_task.join(); - // don't use bar if we can't measure terminal size - let Some((width, height)) = super::term_width_height() else { - return; - }; - let max_bars = (height / 2).saturating_sub(2); - // don't use bars if the terminal is too short - if max_bars == 0 { - return; + self.print_task.assign(print_task()); + } + } + /// Print a progress bar done message + pub(crate) fn print_bar_done(&mut self, result: &BarResult, is_root: bool) { + if lv::PRINT_LEVEL.get() < lv::Print::Normal { + return; + } + if !is_root && self.bar_target.is_some() { + // if bar is animated, don't print child's done messages + return; + } + let message = match result { + BarResult::DontKeep => return, + BarResult::Done(message) => { + self.format_buffer + .reset(self.colors.gray, self.colors.green); + self.format_buffer.push_control(self.colors.green); + message } - self.print_task.assign(print_task(width, max_bars as i32)); + BarResult::Interrupted(message) => { + self.format_buffer + .reset(self.colors.gray, self.colors.yellow); + self.format_buffer.push_control(self.colors.yellow); + message + } + }; + self.format_buffer.push_control("\u{283f}]"); + if !message.starts_with('[') { + self.format_buffer.push_control(" "); } + self.format_buffer.push_str(message); + self.format_buffer.push_lf(); + self.print_format_buffer(); } /// Format and print the message @@ -255,7 +229,7 @@ impl Printer { self.format_buffer.push(']', 1); } } - super::THREAD_NAME.with_borrow(|x| { + THREAD_NAME.with_borrow(|x| { if let Some(x) = x { self.format_buffer.push_control(self.colors.magenta); self.format_buffer.push('[', 1); @@ -272,31 +246,11 @@ impl Printer { self.format_buffer.new_line(); self.format_buffer.push_str(line); } - self.format_buffer.end(); - self.print_format_buffer(); - } - - /// Format and print a progress bar done message - pub(crate) fn print_bar_done(&mut self, message: &str, is_progress_complete: bool) { - if lv::PRINT_LEVEL.get() < lv::Print::Normal { - return; - } - if is_progress_complete { - self.format_buffer - .reset(self.colors.gray, self.colors.green); - self.format_buffer.push_control(self.colors.green); - } else { - self.format_buffer - .reset(self.colors.gray, self.colors.yellow); - self.format_buffer.push_control(self.colors.yellow); - } - self.format_buffer.push_str(message); - self.format_buffer.end(); + self.format_buffer.push_lf(); self.print_format_buffer(); } - fn print_format_buffer(&mut self) { - if !self.prompt_task.active() && self.bars.is_empty() { + if !self.print_task.active() { use std::io::Write; let _ = write!(self.stdout, "{}", self.format_buffer.as_str()); let _ = self.stdout.flush(); @@ -337,76 +291,22 @@ impl Printer { if self.print_task.needs_join { return self.print_task.take(); } - // if there are no bars, then eventually the task will end - let strong_count = self.bars.iter().filter(|x| x.upgrade().is_some()).count(); - if strong_count == 0 { + // if there are no bars and no prompts, then eventually the task will end + // we have to check the strong count and not the bars size, because + // we need to force the last bar to join the printing thread before + // the program exits + let bar_strong_count = self.bars.iter().filter(|x| x.upgrade().is_some()).count(); + if bar_strong_count == 0 && self.pending_prompts.is_empty() { self.print_task.take() } else { None } } - pub(crate) fn take_prompt_task_if_should_join(&mut self) -> Option> { - if self.prompt_task.needs_join { - return self.prompt_task.take(); - } - - if self.pending_prompts.is_empty() { - self.prompt_task.take() - } else { - None - } - } -} - -#[derive(PartialEq, Eq)] -enum Target { - /// Print to Stdout - Stdout, - /// Print to Stderr - Stderr, -} -#[derive(Default)] -struct PrintThread { - needs_join: bool, - handle: Option>, -} -impl PrintThread { - /// Take the handle for joining - fn take(&mut self) -> Option> { - self.needs_join = false; - self.handle.take() - } - - /// Mark the task as will end, so it can be joined - fn mark_join(&mut self) { - self.needs_join = true; - } - - /// If the task is active - fn active(&self) -> bool { - !self.needs_join && self.handle.is_some() - } - - /// Blockingly join the task on the current thread - fn join(&mut self) { - self.needs_join = false; - if let Some(handle) = self.handle.take() { - let _: Result<_, _> = handle.join(); - } - } - - /// Assign a new handle - fn assign(&mut self, handle: JoinHandle<()>) { - self.needs_join = false; - self.handle = Some(handle); - } } -fn print_task(original_width: usize, max_bars: i32) -> JoinHandle<()> { - use std::fmt::Write as _; - - // 50ms between each cycle - const INTERVAL: Duration = Duration::from_millis(10); +/// Printing thread that handles progress bar animation and printing during progress bar display +fn print_task() -> JoinHandle<()> { + // progress bar animation chars #[rustfmt::skip] const CHARS: [char; 30] = [ '\u{280b}', '\u{280b}', '\u{280b}', '\u{280b}', '\u{280b}', @@ -422,13 +322,11 @@ fn print_task(original_width: usize, max_bars: i32) -> JoinHandle<()> { // if drop() is working to prevent holding the lock during sleep #[inline(always)] fn print_loop( - original_width: usize, - max_bars: i32, - tick: u32, + tick: Tick, buffer: &mut String, temp: &mut String, lines: &mut i32, - ) -> std::ops::ControlFlow<()> { + ) -> ControlFlow<()> { // This won't cause race condition where // the return value of start_print_task is put // into the handle after the task is ended, @@ -440,92 +338,156 @@ fn print_task(original_width: usize, max_bars: i32) -> JoinHandle<()> { printer.print_task.mark_join(); } #[inline(always)] - fn clear(b: &mut String, lines: i32) { + fn clear(b: &mut String, lines: &mut i32) { b.clear(); - b.push_str("\r\x1b[K"); // erase the last spacing line (... and X more) - for _ in 0..lines { + b.push_str("\r\x1b[K"); // erase the last spacing line + for _ in 0..*lines { b.push_str("\x1b[1A\x1b[K"); // move up one line and erase it } + *lines = 0; } - // std::thread::sleep(INTERVAL); - clear(buffer, *lines); - // scope for locking the printer + // first check if there are any pending prompts + // scope for locking the printer for checking prompts + { + let Ok(mut printer_guard) = PRINTER.lock() else { + return ControlFlow::Break(()); + }; + let Some(printer) = printer_guard.as_mut() else { + return ControlFlow::Break(()); + }; + let task = printer.pending_prompts.pop_front(); + let is_stdin_terminal = printer.is_stdin_terminal; + if let Some(mut task) = task { + use std::io::Write as _; + // print the prompt + let control = printer.controls.move_to_begin_and_clear; + let _ = write!(printer.stdout, "{}{}", control, task.prompt); + let _ = printer.stdout.flush(); + + // drop the lock while we wait for user input + drop(printer_guard); + // if there is a prompt, don't clear the previous progress bar yet, + // since we want to display the prompt after the progress bars + + // we know the prompt string does not end with a new line (because of + // the prompt prefix), so the number of lines to display + // is exactly .lines().count() + let mut l = task.prompt.lines().count() as i32; + // however, if stdin is not terminal, then user won't press enter, + // and we actually have 1 fewer line + if !is_stdin_terminal { + l = l.saturating_sub(1) + } + *lines += l; + // process this prompt + #[cfg(feature = "prompt-password")] + let (result, is_password) = if task.is_password { + (password::read_password(), true) + } else { + (read_plaintext(temp), false) + }; + #[cfg(not(feature = "prompt-password"))] + let (result, is_password) = (read_plaintext(temp), false); + + // clear sensitive information in the memory + crate::str::zero(temp); + // now, re-print the prompt text to the buffer without the prompt prefix + if !is_password { + while !task.prompt.ends_with('\n') { + task.prompt.pop(); + } + task.prompt.pop(); // pop the final new line + } + // add the prompt to the print buffer + if let Ok(mut printer) = PRINTER.lock() { + if let Some(printer) = printer.as_mut() { + printer.buffered.push_str(&task.prompt); + printer.buffered.push('\n'); + } + } + // send the result of the prompt + let _ = task.send.send(result); + + // we only process one prompt at a time + } + } + + // clear previous progress bars and prompts + clear(buffer, lines); + // lock the printer again for printing progress bars let Ok(mut printer) = PRINTER.lock() else { - return std::ops::ControlFlow::Break(()); + return ControlFlow::Break(()); + }; + let Some(printer) = printer.as_mut() else { + return ControlFlow::Break(()); }; - if printer.bar_target.is_none() { - on_task_end(&mut printer); - return std::ops::ControlFlow::Break(()); - } - if printer.prompt_task.active() { - // don't do anything when there's a prompt, - // since that will cause cursor to change position - return std::ops::ControlFlow::Continue(()); - } - let now = std::time::Instant::now(); + if let Some(bar_target) = printer.bar_target { + // print the bars, after processing buffered messages - // remeasure terminal width on every cycle - let width = super::term_width().unwrap_or(original_width); + // remeasure terminal width on every cycle + let width = fmt::term_width_or_max(); - if printer.bar_target == Some(Target::Stdout) { - // add the buffered messages - printer.take_buffered(buffer); - } else { - printer.print_buffered(); - } - // print the bars - let mut more_bars = -max_bars; - buffer.push_str(printer.colors.yellow); - *lines = 0; - let anime = CHARS[(tick as usize) % CHARS.len()]; - printer.bars.retain(|bar| { - let Some(bar) = bar.upgrade() else { - return false; + if bar_target == Target::Stdout { + // add the buffered messages + printer.take_buffered(buffer); + } else { + printer.print_buffered(); + } + // print the bars + buffer.push_str(printer.colors.yellow); + let anime = CHARS[(tick as usize) % CHARS.len()]; + + let mut formatter = BarFormatter { + colors: printer.colors, + bar_color: printer.colors.yellow, + width, + tick, + now: &mut None, + out: buffer, + temp, }; - if more_bars < 0 { + printer.bars.retain(|bar| { + let Some(bar) = bar.upgrade() else { + // bar is done + return false; + }; if width >= 2 { - buffer.push(anime); - buffer.push(']'); - bar.format(width - 2, now, tick, INTERVAL, buffer, temp); + formatter.out.push(anime); + formatter.out.push(']'); + *lines += bar.format(&mut formatter); + } else { + formatter.out.push('\n'); + *lines += 1; } - buffer.push('\n'); - *lines += 1; - } - more_bars += 1; - true - }); - - if more_bars > 0 { - temp.clear(); - if write!(temp, " ... and {more_bars} more").is_err() { - temp.clear(); - } - if width >= temp.len() { - buffer.push_str(temp); - buffer.push_str(printer.colors.reset); - buffer.push('\r'); - } - } else { - buffer.push_str(printer.colors.reset); + true + }); } - + buffer.push_str(printer.colors.reset); printer.print_to_bar_target(buffer); + let bars_empty = printer.bars.is_empty(); + let prompts_empty = printer.pending_prompts.is_empty(); - // check exit - if printer.bars.is_empty() { - on_task_end(&mut printer); + if bars_empty { // erase the bars - clear(buffer, *lines); + clear(buffer, lines); + printer.print_to_bar_target(buffer); + } + + // check exit + if bars_empty && prompts_empty { + // nothing else to do, mark the task done, + // so the printer knows to join this thread (after we drop the lock guard) + // whenever someone calls, instead of posting to this thread + on_task_end(printer); // we know the printer buffer is empty // because we just printed all of it while having - // the lock on the printer - printer.print_to_bar_target(buffer); - std::ops::ControlFlow::Break(()) - } else { - std::ops::ControlFlow::Continue(()) + // the lock on the printer, no need to print again + return ControlFlow::Break(()); } + + ControlFlow::Continue(()) } std::thread::spawn(move || { @@ -537,17 +499,10 @@ fn print_task(original_width: usize, max_bars: i32) -> JoinHandle<()> { // how many bars were printed let mut lines = 0; loop { - match print_loop( - original_width, - max_bars, - tick, - &mut buffer, - &mut temp, - &mut lines, - ) { - std::ops::ControlFlow::Break(_) => break, + match print_loop(tick, &mut buffer, &mut temp, &mut lines) { + ControlFlow::Break(_) => break, _ => { - std::thread::sleep(INTERVAL); + std::thread::sleep(TICK_INTERVAL); tick = tick.wrapping_add(1); } }; @@ -555,53 +510,61 @@ fn print_task(original_width: usize, max_bars: i32) -> JoinHandle<()> { }) } -// note that for interactive io, it's recommended to use blocking io directly -// on a thread instead of tokio -fn prompt_task( - first_send: oneshot::Sender>, - _is_password: bool, -) -> JoinHandle<()> { - use std::io::Write; - let mut stdout = std::io::stdout(); - std::thread::spawn(move || { - let mut send = first_send; - let mut _is_password = _is_password; - let mut buf = String::new(); - loop { - buf.clear(); - #[cfg(feature = "prompt-password")] - let result = if _is_password { - super::prompt_password::read_password() - } else { - std::io::stdin() - .read_line(&mut buf) - .map(|_| buf.trim().to_string().into()) - }; - #[cfg(not(feature = "prompt-password"))] - let result = std::io::stdin() - .read_line(&mut buf) - .map(|_| buf.trim().to_string().into()); - let _ = send.send(result); - let Ok(mut printer) = super::PRINTER.lock() else { - break; - }; - let Some(next) = printer.pending_prompts.pop_front() else { - printer.prompt_task.mark_join(); - break; - }; - let _ = write!( - stdout, - "{}{}{}", - printer.controls.move_to_begin_and_clear, printer.buffered, next.prompt - ); - printer.buffered.clear(); - let _ = stdout.flush(); - send = next.send; - - #[cfg(feature = "prompt-password")] - { - _is_password = next.is_password; - } +fn read_plaintext(buf: &mut String) -> io::Result { + buf.clear(); + io::stdin() + .read_line(buf) + .map(|_| buf.trim().to_string().into()) +} + +struct PromptTask { + send: OnceSend>, + prompt: String, + #[cfg(feature = "prompt-password")] + is_password: bool, +} + +/// For synchornizing with the printer +#[derive(Default)] +struct PrintingThread { + needs_join: bool, + /// Handle for the printing task, None means + /// either no printing task is running, or, the printing + /// task is terminating + handle: Option>, +} +impl PrintingThread { + /// Take the handle for joining + fn take(&mut self) -> Option> { + self.needs_join = false; + self.handle.take() + } + /// Mark the task as will end, so it can be joined + fn mark_join(&mut self) { + self.needs_join = true; + } + /// If the task is active + fn active(&self) -> bool { + !self.needs_join && self.handle.is_some() + } + /// Blockingly join the task on the current thread + fn join(&mut self) { + self.needs_join = false; + if let Some(handle) = self.handle.take() { + let _: Result<_, _> = handle.join(); } - }) + } + /// Assign a new handle + fn assign(&mut self, handle: JoinHandle<()>) { + self.needs_join = false; + self.handle = Some(handle); + } +} + +#[derive(Clone, Copy, PartialEq, Eq)] +enum Target { + /// Print to Stdout + Stdout, + /// Print to Stderr + Stderr, } diff --git a/packages/copper/src/cli/progress/builder.rs b/packages/copper/src/cli/progress/builder.rs new file mode 100644 index 0000000..55616a6 --- /dev/null +++ b/packages/copper/src/cli/progress/builder.rs @@ -0,0 +1,216 @@ +use std::sync::Arc; +use std::sync::atomic::{AtomicUsize, Ordering}; + +use crate::cli::progress::{Estimater, ProgressBar, State, StateImmut}; + +/// Builder for a progress bar +#[derive(Debug, Clone)] // Clone sometimes needed to build by ref.. without unsafe +pub struct ProgressBarBuilder { + /// The message prefix for the progress bar + message: String, + /// Total steps (None = unbounded, 0 = not known yet) + total: Option, + /// The progress bar is for displaying bytes + total_is_in_bytes: bool, + /// If the bar should be kept after it's done + keep: Option, + /// If ETA should be visible (only effective if total is finite) + show_eta: bool, + /// If percentage should be visible (only effective if total is finite) + show_percentage: bool, + /// Message to display after done, instead of the default + done_message: Option, + /// Message to display if the bar is interrupted + interrupted_message: Option, + /// Maximum number of children to display at a time + max_display_children: usize, + /// Optional parent of the bar + parent: Option>, +} + +impl ProgressBarBuilder { + /// Start building a progress bar. Note [`cu::progress`](fn@crate::progress) is the canonical shorthand + pub fn new(message: String) -> Self { + Self { + message, + total: None, + total_is_in_bytes: false, + keep: None, + show_eta: true, + show_percentage: true, + done_message: None, + interrupted_message: None, + max_display_children: usize::MAX / 2, + parent: None, + } + } + /// Set the total steps. `0` means total is unknown, which can be set + /// at a later point. + /// + /// By default, the progress bar is "unbounded", meaning there is no + /// individual steps + /// + /// ```rust + /// # use pistonite_cu as cu; + /// cu::progress("doing something").total(10); + /// ``` + #[inline(always)] + pub fn total(mut self, total: usize) -> Self { + self.total = Some(total as u64); + self + } + + /// Set the total as a `u64` on platforms where `usize` is less than 64 bits + #[cfg(not(target_pointer_width = "64"))] + pub fn total_u64(mut self, total: u64) -> Self { + self.total = Some(total); + self + } + + /// Set the total bytes and set the progress to be displayed using byte units (SI). + /// `0` means total is unknown, which can be set at a later point. + /// + /// ```rust + /// # use pistonite_cu as cu; + /// cu::progress("doing something").total_bytes(1000000); + /// ``` + #[inline(always)] + pub fn total_bytes(mut self, total: u64) -> Self { + self.total = Some(total); + self.total_is_in_bytes = true; + self + } + + /// Set if the progress bar should be kept in the output + /// after it's done. + /// + /// Default is `true` for root progress bars and `false` + /// for child progress bars + /// + /// ```rust + /// # use pistonite_cu as cu; + /// cu::progress("doing something").keep(false); + /// ``` + #[inline(always)] + pub fn keep(mut self, keep: bool) -> Self { + self.keep = Some(keep); + self + } + + /// Set if ETA (estimated time) should be displayed. + /// Only effective if total is not zero (i.e. not unbounded). + /// Default is `true` + /// + /// ```rust + /// # use pistonite_cu as cu; + /// cu::progress("doing something").total(10).eta(false); + /// ``` + #[inline(always)] + pub fn eta(mut self, show: bool) -> Self { + self.show_eta = show; + self + } + + /// Set if percentage should be displayed. + /// Only effective if total is not zero (i.e. not unbounded). + /// Default is `true` + /// + /// ```rust + /// # use pistonite_cu as cu; + /// cu::progress("doing something").total(10).percentage(false); + /// ``` + #[inline(always)] + pub fn percentage(mut self, show: bool) -> Self { + self.show_percentage = show; + self + } + + /// Set a message to be displayed when the progress is done. + /// Requires `keep(true)` - which is the default, but + /// `when_done` will not automatically turn it on for you. + /// + /// Default is the message of the progress bar followed by `done`. + /// + /// ```rust + /// # use pistonite_cu as cu; + /// cu::progress("doing something").when_done("something is done!"); + /// ``` + #[inline(always)] + pub fn when_done(mut self, message: impl Into) -> Self { + self.done_message = Some(message.into()); + self + } + + /// Set a message to be displayed when the progress is interrupted. + /// + /// Default is the message of the progress bar followed by `interrupted`. + /// + /// ```rust + /// # use pistonite_cu as cu; + /// cu::progress("doing something").when_interrupt("something is interrupted!"); + /// ``` + #[inline(always)] + pub fn when_interrupt(mut self, message: impl Into) -> Self { + self.interrupted_message = Some(message.into()); + self + } + + /// Set the max number of children to display at a time. + /// Default is unbounded. + /// + /// ```rust + /// # use pistonite_cu as cu; + /// cu::progress("doing something").max_display_children(30); + /// ``` + pub fn max_display_children(mut self, num: usize) -> Self { + self.max_display_children = num; + self + } + + /// Set the parent progress bar. + /// + /// If the parent is known to be `Some`, use `parent.child(...)` instead + pub fn parent(mut self, parent: Option>) -> Self { + self.parent = parent; + self + } + + /// Build and start displaying the bar in the console + pub fn spawn(self) -> Arc { + let keep = self.keep.unwrap_or(self.parent.is_none()); + let done_message = if keep { + match self.done_message { + None => { + if self.message.is_empty() { + Some("done".to_string()) + } else { + Some(format!("{}: done", self.message)) + } + } + Some(x) => Some(x), + } + } else { + None + }; + let state_immut = StateImmut { + id: next_id(), + parent: self.parent.as_ref().map(Arc::clone), + prefix: self.message, + done_message, + interrupted_message: self.interrupted_message, + show_percentage: self.show_percentage, + unbounded: self.total.is_none(), + display_bytes: self.total_is_in_bytes, + max_display_children: self.max_display_children, + }; + let eta = self.show_eta.then(Estimater::new); + let state = State::new(self.total.unwrap_or(0), eta); + + ProgressBar::spawn(state_immut, state, self.parent) + } +} + +fn next_id() -> usize { + static ID: AtomicUsize = AtomicUsize::new(1); + ID.fetch_add(1, Ordering::SeqCst) +} diff --git a/packages/copper/src/cli/progress/eta.rs b/packages/copper/src/cli/progress/eta.rs new file mode 100644 index 0000000..def6b2f --- /dev/null +++ b/packages/copper/src/cli/progress/eta.rs @@ -0,0 +1,74 @@ +use std::time::Instant; + +use crate::cli::{TICK_INTERVAL, Tick}; + +/// Estimate the time for progress bar +#[derive(Debug)] +pub struct Estimater { + /// Time when the progress started + start: Instant, + /// If the ETA is accurate enough to be displayed + is_reasonably_accurate: bool, + /// Step number when we last estimated ETA + last_step: u64, + /// Tick number when we last estimated ETA + last_tick: u32, + /// Last calculation, in seconds + previous_eta: f32, +} + +impl Estimater { + pub fn new() -> Self { + Self { + start: Instant::now(), + is_reasonably_accurate: false, + last_step: 0, + last_tick: 0, + previous_eta: 0.0, + } + } + + pub fn update( + &mut self, + now: &mut Option, + current: u64, + total: u64, + tick: Tick, + ) -> Option { + let now = match now { + None => { + let n = Instant::now(); + *now = Some(n); + n + } + Some(n) => *n, + }; + let elapsed = (now - self.start).as_secs_f32(); + let secs_per_step = elapsed / current as f32; + let mut eta = secs_per_step * (total - current) as f32; + if current == self.last_step { + // subtract time passed since updating to this step + let elapased_since_current = (TICK_INTERVAL * (tick - self.last_tick)).as_secs_f32(); + if elapased_since_current > eta { + self.last_step = current; + self.last_tick = tick; + } + eta = (eta - elapased_since_current).max(0.0); + // only start showing ETA if it's reasonably accurate + if !self.is_reasonably_accurate && eta < self.previous_eta - TICK_INTERVAL.as_secs_f32() + { + self.is_reasonably_accurate = true; + } + self.previous_eta = eta; + } else { + self.last_step = current; + self.last_tick = tick; + } + + if !self.is_reasonably_accurate { + None + } else { + Some(eta) + } + } +} diff --git a/packages/copper/src/cli/progress/macros.rs b/packages/copper/src/cli/progress/macros.rs new file mode 100644 index 0000000..dadced8 --- /dev/null +++ b/packages/copper/src/cli/progress/macros.rs @@ -0,0 +1,46 @@ +/// Update a [progress bar](fn@crate::progress) +/// +/// The macro takes 2 parts separated by comma `,`: +/// - An expression for updating the progress: +/// - Optional format args for updating the message. +/// +/// The progress update expression can be one of: +/// - `bar = i`: set the progress to `i` +/// - `bar += i`: increment the steps by i +/// - `bar`: don't update the progress +/// +/// , where `bar` is an ident +/// +/// The format args can be omitted to update the progress without +/// updating the message +/// +/// # Examples +/// ```rust,no_run +/// # use pistonite_cu as cu; +/// let bar = cu::progress("10 steps").total(10).spawn(); +/// // update the current count and message +/// let i = 1; +/// cu::progress!(bar = i, "doing step {i}"); +/// // update the current count without changing message +/// cu::progress!(bar += 2); +/// // update the message without changing current step +/// cu::progress!(bar, "doing the thing"); +/// ``` +#[macro_export] +macro_rules! progress { + ($bar:ident, $($fmt_args:tt)*) => { + $bar.__inc(0u64, Some(format!($($fmt_args)*))) + }; + ($bar:ident += $inc:expr) => { + $bar.__inc({ $inc } as u64, None) + }; + ($bar:ident += $inc:expr, $($fmt_args:tt)*) => { + $bar.__inc({ $inc } as u64, Some(format!($($fmt_args)*))) + }; + ($bar:ident = $x:expr) => { + $bar.__set({ $x } as u64, None) + }; + ($bar:ident = $x:expr, $($fmt_args:tt)*) => { + $bar.__set({ $x } as u64, Some(format!($($fmt_args)*))) + }; +} diff --git a/packages/copper/src/cli/progress/mod.rs b/packages/copper/src/cli/progress/mod.rs new file mode 100644 index 0000000..4e85c3d --- /dev/null +++ b/packages/copper/src/cli/progress/mod.rs @@ -0,0 +1,151 @@ +/// # Progress Bars +/// Progress bars are a feature in the print system. It is aware of the printing/prompting going on +/// in the console and will keep the bars at the bottom of the console without interferring +/// with the other outputs. +/// +/// ## Components +/// A bar has the following display components +/// - Step display: Displays the current and total steps. For example, `[42/100]`. Will not display +/// for bars that are unbounded. Bars that are not unbounded but the total is not set +/// will show total as `?`. The step display can also be configured to a style more suitable +/// for displaying bytes (for example downloading or processing file), like `10.0K / 97.3M` +/// - Prefix: A string configured once when launching the progress bar +/// - Percentage: Percentage display for the current and total steps, For example `42.00%`. +/// This can be turned off if not needed +/// - ETA: Estimated remaining time. This can be turned off if not needed +/// - Message: A message that can be set while the progress bar is showing. For example, +/// this can be the name of the current file being processed, etc. +/// +/// With everything displayed, it will look something like this: +/// ```text +/// X][42/100] prefix: 42.00% ETA 32.35s processing the 42th item +/// ``` +/// (`X`) is where the animated spinner is +/// +/// ## Progress Tree +/// You can display progress bars with a hierarchy if desired. The progress bars +/// will be organized as an directed acyclic graph (i.e. a tree). Special characters +/// will be used to draw the tree in the terminal. +/// +/// Each progress bar holds a strong ref to its parent, and weak refs to all of its children. +/// The printer keeps weak refs to all root progress bars (i.e. one without a parent). +/// +/// ## State and Output +/// Each progress bar can have 3 states: `progress`, `done`, and `interrupted`. +/// +/// When in `progress`, the bar will be animated if the output is a terminal. Otherwise, +/// updates will be ignored. +/// +/// The bar will be `done` when all handles are dropped if 1 of the following is true: +/// - The bar has finite total, and current step equals total step +/// - The bar is unbounded, and `.done()` is called on any handle +/// +/// If neither is true when all handles are dropped, the bar becomes `interrupted`. +/// This makes the bar easier to use with control flows. When the bar is in this state, +/// it will print an interrupted message to the regular print stream, like +/// ```text +/// X][42/100] prefix: interrupted +/// ``` +/// This message is customizable when building the progress bar. All of its children +/// that are interrupted will also be printed. All children that are `done` will only be +/// printed if `keep` is true for that children (see below). The interrupted message is printed +/// regardless if the output is terminal or not. +/// +/// When the progress bar is done, it may print a "done message" depending on +/// if it has a parent and the `keep` option: +/// | Has parent (i.e. is child) | Keep | Behavior | +/// |-|-|-| +/// | Yes | Yes | Done message will be displayed under the parent, but the bar will disappear completely when the parent is done | +/// | Yes | No | The bar will disappear after it's done | +/// | No | Yes | The bar will print a done message to the regular print stream when done, no children will be printed | +/// | No | No | The bar will disappear after done, no children will be printed | +/// +/// The done message is also customizable when building the bar. Note (from the table) that it will +/// be effective in some way if the `keep` option is true. Setting a done message +/// does not automatically set `keep` to true. +/// +/// The default done message is something like below, will be displayed in green. +/// ```text +/// X][100/100] prefix: done +/// ``` +/// +/// ## Updating the bar +/// The [`progress`](macro@crate::progress) macro is used to update the progress bar. +/// For example: +/// +/// ```rust +/// # use pistonite_cu as cu; +/// let bar = cu::progress("doing something").total(10).spawn(); +/// for i in 0..10 { +/// cu::progress!(bar = i, "doing {i}th step"); +/// } +/// drop(bar); +/// ``` +/// +/// ## Building the bar +/// This function `cu::progress` will make a [`ProgressBarBuilder`] +/// with these default configs: +/// - Total steps: unbounded +/// - Keep after done: `true` +/// - Show ETA: `true` (only effective if steps is finite) +/// - Finish message: Default +/// - Interrupted message: Default +/// +/// See [`ProgressBarBuilder`] for builder methods +/// +/// ## Print Levels +/// The bar final messages are suppressed at `-q` and the bar animations are suppressed at `-qq` +/// +/// ## Other considerations +/// If the progress bar print section exceeds the terminal height, +/// it will probably not render properly. Keep in mind when you +/// are displaying a large number of progress bars. +/// +/// You can use `.max_display_children()` to set the maximum number of children +/// to display at a time. However, there is no limit on the number of root progress bars. +#[inline(always)] +pub fn progress(message: impl Into) -> ProgressBarBuilder { + ProgressBarBuilder::new(message.into()) +} + +mod eta; +pub use eta::Estimater; +mod state; +pub use state::ProgressBar; +use state::{State, StateImmut}; +mod builder; +pub use builder::ProgressBarBuilder; +mod util; +pub use util::{BarFormatter, BarResult}; +use util::{ChildState, ChildStateStrong}; +mod macros; + +// spawn_iter stuff, keep for reference, not sure if needed yet +// .enumerate seems more readable +/* +/// In the example above, you can also attach it to an iterator directly. +/// The builder will call `size_hint()` once and set the total on the bar, +/// and will automatically mark it as done if `next()` returns `None`. +/// +/// If the default iteration behavior of `spawn_iter` is not desirable, use `spawn` +/// and iterate manually. +/// ```rust +/// # use pistonite_cu as cu; +/// for i in cu::progress("doing something").spawn_iter(0..10) { +/// cu::print!("doing {i}th step"); +/// } +/// ``` +/// +/// Note that in the code above, we didn't have a handle to the bar directly +/// to update the message, we can fix that by getting the bar from the iter +/// +/// ```rust +/// # use pistonite_cu as cu; +/// let mut iter = cu::progress("doing something").spawn_iter(0..10); +/// let bar = iter.bar(); +/// for i in iter { +/// // bar = i is handled by the iterator automatically +/// cu::progress!(bar, "doing {i}th step"); +/// } +/// ``` +*/ diff --git a/packages/copper/src/cli/progress/state.rs b/packages/copper/src/cli/progress/state.rs new file mode 100644 index 0000000..8ce8f90 --- /dev/null +++ b/packages/copper/src/cli/progress/state.rs @@ -0,0 +1,615 @@ +use std::sync::{Arc, Mutex}; +use std::time::Instant; + +use crate::cli::Tick; +use crate::cli::fmt::ansi; +use crate::cli::printer::PRINTER; +use crate::cli::progress::{ + BarFormatter, BarResult, ChildState, ChildStateStrong, Estimater, ProgressBarBuilder, +}; + +const CHAR_BAR_TICK: char = '\u{251C}'; // |> +const CHAR_BAR: char = '\u{2502}'; // | +const CHAR_TICK: char = '\u{2514}'; // > + +/// Handle for a progress bar (This is the internal state, the handle is `Arc`) +/// +/// See [Progress Bars](fn@crate::progress) +#[derive(Debug)] +pub struct ProgressBar { + pub(crate) state: StateImmut, + state_mut: Mutex, +} +impl ProgressBar { + pub(crate) fn spawn( + state: StateImmut, + state_mut: State, + parent: Option>, + ) -> Arc { + let bar = Arc::new(Self { + state, + state_mut: Mutex::new(state_mut), + }); + match parent { + Some(p) => { + if let Ok(mut p) = p.state_mut.lock() { + p.add_child(&bar); + } + } + None => { + if let Ok(mut printer) = PRINTER.lock() { + if let Some(printer) = printer.as_mut() { + printer.add_progress_bar(&bar); + } + } + } + } + bar + } + #[doc(hidden)] + #[inline(always)] + pub fn __set(self: &Arc, current: u64, message: Option) { + if let Ok(mut bar) = self.state_mut.lock() { + bar.unreal_current = current; + if let Some(x) = message { + bar.set_message(&x); + } + } + } + + #[doc(hidden)] + #[inline(always)] + pub fn __inc(self: &Arc, amount: u64, message: Option) { + if let Ok(mut bar) = self.state_mut.lock() { + bar.unreal_current = bar.unreal_current.saturating_add(amount); + if let Some(x) = message { + bar.set_message(&x); + } + } + } + + /// Set the total steps (if the progress is finite) + pub fn set_total(&self, total: u64) { + if total == 0 { + // 0 is a special value, so we do not allow setting it + return; + } + if let Ok(mut bar) = self.state_mut.lock() { + bar.unreal_total = total; + } + } + + /// Start building a child progress bar + /// + /// Note that the child builder will keep this bar alive (displayed), even + /// if the child is not spawned + #[inline(always)] + pub fn child(self: &Arc, message: impl Into) -> ProgressBarBuilder { + ProgressBarBuilder::new(message.into()).parent(Some(Arc::clone(self))) + } + + /// Mark the progress bar as done and drop the handle. + /// + /// This needs to be called if the bar is unbounded. Otherwise, + /// the bar will display in the interrupted state when dropped. + /// + /// If the progress is finite, then interrupted state is automatically + /// determined (`current != total`) + pub fn done(self: Arc) { + if self.state.unbounded { + if let Ok(mut bar) = self.state_mut.lock() { + bar.unreal_current = 1; + bar.unreal_total = 1; + } + } + } + + /// Same as [`done`](Self::done), but does not drop the bar. + pub fn done_by_ref(&self) { + if self.state.unbounded { + if let Ok(mut bar) = self.state_mut.lock() { + bar.unreal_current = 1; + bar.unreal_total = 1; + } + } + } + + /// Format the bar + #[inline(always)] + pub(crate) fn format(&self, fmt: &mut BarFormatter<'_, '_, '_>) -> i32 { + self.format_at_depth(0, &mut String::new(), fmt) + } + + /// Format the bar at depth + fn format_at_depth( + &self, + depth: usize, + hierarchy: &mut String, + fmt: &mut BarFormatter<'_, '_, '_>, + ) -> i32 { + let Ok(mut bar) = self.state_mut.lock() else { + return 0; + }; + bar.format_at_depth(depth, hierarchy, fmt, &self.state) + } +} + +impl Drop for ProgressBar { + fn drop(&mut self) { + let result = match self.state_mut.lock() { + Err(_) => BarResult::DontKeep, + Ok(bar) => bar.check_result(&self.state), + }; + if let Some(parent) = &self.state.parent { + // inform parent our result + if let Ok(mut parent_state) = parent.state_mut.lock() { + parent_state.child_done(self.state.id, result.clone()); + } + } + let handle = { + // scope for printer lock + let Ok(mut printer) = PRINTER.lock() else { + return; + }; + let Some(printer) = printer.as_mut() else { + return; + }; + printer.print_bar_done(&result, self.state.parent.is_none()); + printer.take_print_task_if_should_join() + }; + if let Some(x) = handle { + let _: Result<(), _> = x.join(); + } + } +} + +/// Internal, immutable state of progress bar +#[derive(Debug)] +pub struct StateImmut { + /// An ID + pub id: usize, + /// Parent of this bar + pub parent: Option>, + /// The prefix message (corresponds to message in the builder) + pub prefix: String, + /// None means don't keep the progress bar printed + /// (the default done message is formatted at spawn time) + pub done_message: Option, + /// None means use the default + pub interrupted_message: Option, + /// If percentage field is shown + pub show_percentage: bool, + /// If the steps are unbounded + pub unbounded: bool, + /// Display the progress using bytes format + pub display_bytes: bool, + /// Max number of children to display, + /// children after the limit will only display one line "... and X more" + pub max_display_children: usize, +} + +/// Internal mutable state +#[derive(Debug)] +pub struct State { + unreal_total: u64, + unreal_current: u64, + message: String, + eta: Option, + children: Vec, +} +impl State { + pub fn new(total: u64, eta: Option) -> Self { + Self { + unreal_total: total, + unreal_current: 0, + message: String::new(), + eta, + children: vec![], + } + } + #[inline(always)] + fn estimate_remaining( + &mut self, + unbounded: bool, + now: &mut Option, + tick: Tick, + ) -> Option { + if unbounded || self.unreal_total == 0 { + return None; + } + self.eta.as_mut()?.update( + now, + self.unreal_current.min(self.unreal_total), + self.unreal_total, + tick, + ) + } + #[inline(always)] + fn real_current_total(&self, unbounded: bool) -> (u64, Option) { + if unbounded { + (0, None) + } else if self.unreal_total == 0 { + // total not known + (self.unreal_current, None) + } else { + ( + self.unreal_current.min(self.unreal_total), + Some(self.unreal_total), + ) + } + } + + pub fn add_child(&mut self, child: &Arc) { + self.children + .push(ChildState::Progress(child.state.id, Arc::downgrade(child))) + } + + pub fn child_done(&mut self, child_id: usize, mut result: BarResult) { + self.children.retain_mut(|child| { + let ChildState::Progress(id, _) = child else { + return true; + }; + if *id != child_id { + return true; + } + match std::mem::take(&mut result) { + BarResult::DontKeep => false, + BarResult::Done(message) => { + *child = ChildState::Done(message); + true + } + BarResult::Interrupted(message) => { + *child = ChildState::Interrupted(message); + true + } + } + }); + } + + pub fn check_result(&self, state: &StateImmut) -> BarResult { + let is_interrupted = (self.unreal_current == 0 && self.unreal_total == 0) + || (self.unreal_current < self.unreal_total); + if !is_interrupted { + match &state.done_message { + None => BarResult::DontKeep, + Some(message) => { + let message = + self.format_finish_message(message, state.unbounded, state.display_bytes); + BarResult::Done(message) + } + } + } else { + match &state.interrupted_message { + None => { + let message = if state.prefix.is_empty() { + self.format_finish_message( + "interrupted", + state.unbounded, + state.display_bytes, + ) + } else { + self.format_finish_message( + &format!("{}: interrupted", state.prefix), + state.unbounded, + state.display_bytes, + ) + }; + BarResult::Interrupted(message) + } + Some(message) => { + let message = + self.format_finish_message(message, state.unbounded, state.display_bytes); + BarResult::Interrupted(message) + } + } + } + } + + pub fn set_message(&mut self, message: &str) { + self.message.clear(); + self.message.push_str(message); + } + + /// Format the bar into the out buffer at the depth + /// + /// If depth is 0, the animation character is already formatted. + /// Otherwise, a "| " should be formatted into the out buffer + /// at the beginning. The `width` passed in is terminal width minus 2. + /// + /// It should also format a new line character into the buffer + /// + /// Return number of lines formatted. + pub fn format_at_depth( + &mut self, + depth: usize, + hierarchy: &mut String, + fmt: &mut BarFormatter<'_, '_, '_>, + state: &StateImmut, + ) -> i32 { + self.format_self(fmt, fmt.width.saturating_sub((depth + 1) * 2), state); + fmt.out.push('\n'); + let mut lines = 1; + // process childrens + let mut i = 0; + let mut num_displayed = 0; + let children_count = self.children.len(); + self.children.retain_mut(|child| { + let out = &mut *fmt.out; + let Some(child) = child.upgrade() else { + i += 1; + return false; // remove the finished child + }; + if num_displayed >= state.max_display_children { + num_displayed += 1; + return true; + } + // format the multi-line syntax + out.push_str(". "); + out.push_str(fmt.colors.gray); + out.push_str(hierarchy); + if i == children_count - 1 { + out.push(CHAR_TICK); + hierarchy.push_str(" "); + } else { + out.push(CHAR_BAR_TICK); + hierarchy.push(CHAR_BAR); + hierarchy.push(' '); + } + out.push(' '); + let width = fmt.width.saturating_sub((depth + 2) * 2); + match child { + ChildStateStrong::Done(message) => { + out.push_str(fmt.colors.green); + format_message_with_width(out, width, message); + out.push('\n'); + lines += 1; + out.push_str(fmt.bar_color); + } + ChildStateStrong::Interrupted(message) => { + out.push_str(fmt.colors.yellow); + format_message_with_width(out, width, message); + out.push('\n'); + lines += 1; + out.push_str(fmt.bar_color); + } + ChildStateStrong::Progress(child) => { + out.push_str(fmt.bar_color); + lines += child.format_at_depth(depth + 1, hierarchy, fmt); + } + } + hierarchy.pop(); + hierarchy.pop(); + i += 1; + num_displayed += 1; + true + }); + if num_displayed > state.max_display_children { + // display the ... and more line + let out = &mut *fmt.out; + out.push_str("| "); + out.push_str(fmt.colors.gray); + for _ in 0..depth { + out.push(CHAR_BAR); + out.push(' '); + } + out.push(CHAR_TICK); + out.push_str(fmt.colors.reset); + use std::fmt::Write as _; + let _ = write!( + out, + " ... and {} more", + num_displayed - state.max_display_children + ); + out.push_str(fmt.bar_color); + out.push('\n'); + lines += 1; + } + // return number of lines + lines + } + + fn format_self( + &mut self, + fmt: &mut BarFormatter<'_, '_, '_>, + mut width: usize, + state: &StateImmut, + ) { + use std::fmt::Write as _; + let out = &mut *fmt.out; + let temp = &mut *fmt.temp; + + // not enough width + match width { + 0 => return, + 1 => { + out.push('.'); + return; + } + 2 => { + out.push_str(".."); + return; + } + 3 => { + out.push_str("..."); + return; + } + 4 => { + out.push_str("[..]"); + return; + } + _ => {} + } + let (current, total) = self.real_current_total(state.unbounded); + // -- + let show_current_total = !state.unbounded; + let show_prefix = !state.prefix.is_empty(); + // -- : + let show_percentage = state.show_percentage && total.is_some(); + let eta = self.estimate_remaining(state.unbounded, fmt.now, fmt.tick); + let show_eta = eta.is_some(); + let show_message = !self.message.is_empty(); + + struct Spacing { + show_separator: bool, + show_space_before_eta: bool, + show_space_before_message: bool, + } + + let spacing = if state.display_bytes { + Spacing { + show_separator: show_prefix + && (show_current_total || show_percentage || show_eta || show_message), + show_space_before_eta: show_percentage || show_current_total, + show_space_before_message: show_percentage || show_current_total || show_eta, + } + } else { + Spacing { + show_separator: show_prefix && (show_percentage || show_eta || show_message), + show_space_before_eta: show_percentage, + show_space_before_message: show_percentage || show_eta, + } + }; + + if !state.display_bytes && show_current_total { + temp.clear(); + // _: fmt for string does not fail + let _ = match total { + None => write!(temp, "{current}/?"), + Some(total) => write!(temp, "{current}/{total}"), + }; + + // .len() is safe because / and numbers have the same byte size and width + // -2 is safe because width > 4 here + width -= 2; + out.push('['); + if temp.len() > width { + // not enough space + for _ in 0..width { + out.push('.'); + } + out.push(']'); + return; + } + + width -= temp.len(); + out.push_str(temp); + out.push(']'); + } + + if width > 0 { + out.push(' '); + width -= 1; + } + + if show_prefix { + width = format_message_with_width(out, width, &state.prefix); + } + + if spacing.show_separator && width > 2 { + width -= 2; + out.push_str(": "); + } + + if state.display_bytes && show_current_total { + temp.clear(); + // _: fmt for string does not fail + let _ = match total { + None => write!(temp, "{}", cu::ByteFormat(current)), + Some(total) => write!( + temp, + "{} / {}", + cu::ByteFormat(current), + cu::ByteFormat(total) + ), + }; + + if width >= temp.len() { + width -= temp.len(); + out.push_str(temp); + } + + if width > 0 { + out.push(' '); + width -= 1; + } + } + + if show_percentage { + // unwrap: total is always Some + let total = total.unwrap(); + if current == total { + if width >= 4 { + width -= 4; + out.push_str("100%") + } + } else { + let percentage = current as f32 * 100f32 / total as f32; + temp.clear(); + // _: fmt for string does not fail + let _ = write!(temp, "{percentage:.2}%"); + if width >= temp.len() { + width -= temp.len(); + out.push_str(temp); + } + } + } + + if let Some(eta) = eta { + // ETA SS.SSs + if spacing.show_space_before_eta && width > 0 { + out.push(' '); + width -= 1; + } + temp.clear(); + // _: fmt for string does not fail + let _ = write!(temp, "ETA {eta:.2}s;"); + if width >= temp.len() { + width -= temp.len(); + out.push_str(temp); + } + } + + if show_message { + if spacing.show_space_before_message && width > 0 { + out.push(' '); + width -= 1; + } + format_message_with_width(out, width, &self.message); + } + } + + fn format_finish_message(&self, message: &str, unbounded: bool, in_bytes: bool) -> String { + if unbounded { + return message.to_string(); + } + let (current, total) = self.real_current_total(unbounded); + match (total, in_bytes) { + (None, false) => { + format!("[{current}/?] {message}") + } + (None, true) => { + let current = cu::ByteFormat(current); + format!("{message} ({current})") + } + (Some(total), false) => { + format!("[{current}/{total}] {message}") + } + (Some(total), true) => { + let current = cu::ByteFormat(current); + let total = cu::ByteFormat(total); + format!("{message} ({current} / {total})") + } + } + } +} + +fn format_message_with_width(out: &mut String, mut width: usize, message: &str) -> usize { + for (c, w) in ansi::with_width(message.chars()) { + if w > width { + break; + } + width -= w; + out.push(c); + } + width +} diff --git a/packages/copper/src/cli/progress/util.rs b/packages/copper/src/cli/progress/util.rs new file mode 100644 index 0000000..62d1ccf --- /dev/null +++ b/packages/copper/src/cli/progress/util.rs @@ -0,0 +1,52 @@ +use std::sync::{Arc, Weak}; +use std::time::Instant; + +use crate::cli::Tick; +use crate::cli::fmt::ansi; +use crate::cli::progress::ProgressBar; + +#[derive(Debug)] +pub enum ChildState { + /// The done message (if `keep` is true) + Done(String), + /// The interrupted message + Interrupted(String), + /// Still running + Progress(usize, Weak), +} +impl ChildState { + pub fn upgrade(&self) -> Option> { + Some(match self { + ChildState::Done(x) => ChildStateStrong::Done(x), + ChildState::Interrupted(x) => ChildStateStrong::Interrupted(x), + ChildState::Progress(_, weak) => ChildStateStrong::Progress(weak.upgrade()?), + }) + } +} + +pub enum ChildStateStrong<'a> { + Done(&'a str), + Interrupted(&'a str), + Progress(Arc), +} + +#[derive(Default, Clone)] +pub enum BarResult { + /// Bar is done and don't keep it + #[default] + DontKeep, + /// Bar is done, with a message to keep + Done(String), + /// Bar is interrupted + Interrupted(String), +} + +pub struct BarFormatter<'a, 'b, 'c> { + pub colors: ansi::Colors, + pub bar_color: &'a str, + pub width: usize, + pub tick: Tick, + pub now: &'c mut Option, + pub out: &'b mut String, + pub temp: &'b mut String, +} diff --git a/packages/copper/src/cli/prompt.rs b/packages/copper/src/cli/prompt.rs new file mode 100644 index 0000000..9deb538 --- /dev/null +++ b/packages/copper/src/cli/prompt.rs @@ -0,0 +1,90 @@ +use crate::cli::printer::PRINTER; +use crate::lv; +use crate::{Atomic, Context as _}; + +pub(crate) static PROMPT_LEVEL: Atomic = + Atomic::new_u8(lv::Prompt::Interactive as u8); + +#[doc(hidden)] +pub fn __prompt_yesno(message: std::fmt::Arguments<'_>) -> crate::Result { + match check_prompt_level(true) { + Ok(false) => {} + other => return other, + }; + let mut answer = false; + __prompt_with_validation(format_args!("{message} [y/n]"), false, |x| { + x.make_ascii_lowercase(); + match x.trim() { + "y" | "yes" => { + answer = true; + Ok(true) + } + "n" | "no" => { + answer = false; + Ok(true) + } + _ => { + crate::hint!("please enter yes or no"); + Ok(false) + } + } + })?; + Ok(answer) +} + +#[doc(hidden)] +pub fn __prompt(message: std::fmt::Arguments<'_>, is_password: bool) -> cu::Result { + check_prompt_level(false)?; + prompt_impl(&format!("{message}"), is_password) +} + +#[doc(hidden)] +pub fn __prompt_with_validation crate::Result>( + message: std::fmt::Arguments<'_>, + is_password: bool, + mut validator: F, +) -> cu::Result { + let message = format!("{message}"); + loop { + let mut result = prompt_impl(&message, is_password)?; + if validator(&mut result)? { + return Ok(result); + } + } +} + +fn prompt_impl(message: &str, is_password: bool) -> cu::Result { + let recv = { + if let Ok(mut printer) = PRINTER.lock() + && let Some(printer) = printer.as_mut() + { + printer.show_prompt(message, is_password) + } else { + crate::bail!("prompt failed: failed to lock global printer"); + } + }; + let result = crate::check!(recv.recv(), "error while showing prompt")?; + crate::check!(result, "io error while showing prompt") +} + +// Ok(true) -> answer Yes +// Ok(false) -> prompt +// Err -> bail +fn check_prompt_level(is_yesno: bool) -> crate::Result { + if is_yesno { + match PROMPT_LEVEL.get() { + // do not even show the prompt if --yes + lv::Prompt::YesOrInteractive | lv::Prompt::YesOrBlock => return Ok(true), + lv::Prompt::Interactive => return Ok(false), + lv::Prompt::Block => {} + } + } else { + if !matches!( + PROMPT_LEVEL.get(), + lv::Prompt::YesOrBlock | lv::Prompt::Block + ) { + return Ok(false); + } + } + crate::bail!("prompt not allowed with --non-interactive"); +} diff --git a/packages/copper/src/cli/thread_name.rs b/packages/copper/src/cli/thread_name.rs new file mode 100644 index 0000000..d74b6af --- /dev/null +++ b/packages/copper/src/cli/thread_name.rs @@ -0,0 +1,11 @@ +use std::cell::RefCell; + +thread_local! { + pub(crate) static THREAD_NAME: RefCell> = const { RefCell::new(None) }; +} + +/// Set the name to show up in messages printed by the current thread +#[inline(always)] +pub fn set_thread_name(name: impl Into) { + THREAD_NAME.with_borrow_mut(|x| *x = Some(name.into())) +} diff --git a/packages/copper/src/co.rs b/packages/copper/src/co.rs deleted file mode 100644 index f26e004..0000000 --- a/packages/copper/src/co.rs +++ /dev/null @@ -1,129 +0,0 @@ -//! `cu::co::` Coroutine driver -//! -//! This library is designed to have flexible coroutine handling, -//! being able to handle `async` both on the current thread, -//! and on one or more background threads. -//! -//! For example, consider these program styles: -//! - everything being `async` - typically involving both CPU-bound -//! work and IO work interwined. Can take advantage of multiple background threads. -//! - Some IO heavy work that doesn't really involve CPU - for example, -//! spawning compiler processes and wait for them, or spawning network requests. -//! Usually won't have significant performance benefit from having multiple background threads. -//! - Heavy CPU work that only has a little IO. Using `async` usually has very little -//! benefit. (Would probably use something like `rayon` to get parallelism). -//! -//! You pick the style you want. -//! -//! # Async entry point -//! -//! With the [`cli`](module@crate::cli) module, you can use the same macro -//! for an async entry point -//! -//! ```rust -//! use std::time::Duration; -//! # use pistonite_cu as cu; -//! #[cu::cli] -//! async fn main(_: cu::cli::Flags) -> cu::Result<()> { -//! cu::info!("doing some work"); -//! tokio::time::sleep(Duration::from_millis(100)).await; -//! cu::info!("done"); -//! Ok(()) -//! } -//! ``` -//! Note that the entry point is still drived by the main thread despite being `async` -//! (even if `coroutine-heavy` feature is enabled), meaning that the above program -//! is still single-threaded! This makes sense because the (fake) workload doesn't benefit -//! at all from having multiple threads. -//! -//! By default, the number of background threads is 1. -//! Enabling the `coroutine-heavy` feature will change it -//! to the number of processors. -//! -//! # Internal Coroutine -//! Some `cu` functions use coroutines internally behind "synchronous" APIs, -//! allowing seamless integration from a synchronous context. -//! -//! For example, `cu` uses coroutines to drive inputs and outputs from a command: -//! ```rust,no_run -//! # use pistonite_cu as cu; -//! use cu::pre::*; -//! -//! #[cu::cli] -//! fn main(_: cu::cli::Flags) -> cu::Result<()> { -//! let git = cu::which("git")?; -//! let child1 = git.command() -//! .args(["clone", "https://example1.git", "dest1", "--progress"]) -//! .stdin_null() -//! // use a progress bar to display progress, and print other -//! // messages as info -//! .stdoe(cu::pio::spinner("cloning example1").info()) -//! .spawn()?.0; -//! // same configuration -//! let child2 = git.command() -//! .args(["clone", "https://example2.git", "dest2", "--progress"]) -//! .stdin_null() -//! .stdoe(cu::pio::spinner("cloning example2").info()) -//! .spawn()?.0; -//! -//! // Both childs are now running as separate processes in the OS. -//! // Also, IO from both childs are drived by the same background thread. -//! // You can block the main thread to do other work, and it will not -//! // block the child from printing messages -//! -//! // since we don't get benefit from one child finishing early -//! // here, we just wait for them in order -//! child1.wait_nz()?; -//! child2.wait_nz()?; -//! -//! Ok(()) -//! } -//! ``` -//! -//! # `co_*` APIs -//! Many APIs in `cu` has a same version with `co_` prefix. -//! These are designed to be called when you are already in an asynchronous -//! context. For example, we can rewrite the example above using `co_wait_nz`. -//! Note that in this case, there's no benefit of using `co_spawn`/`co_wait_nz`, -//! since we are not doing any extra work. -//! -//! ```rust,no_run -//! # use pistonite_cu as cu; -//! use cu::pre::*; -//! #[cu::cli] -//! async fn main(_: cu::cli::Flags) -> cu::Result<()> { -//! let git = cu::which("git")?; -//! let child1 = git.command() -//! .args(["clone", "https://example1.git", "dest1", "--progress"]) -//! .stdin_null() -//! .stdoe(cu::pio::spinner("cloning example1").info()) -//! // using co_spawn() will do the work needed at spawn time -//! // using the current async context, instead of off-loading -//! // it to a background thread. -//! .co_spawn().await?.0; -//! // however, note that the IO work, once spawned, are still -//! // driven by a background thread regardless of which spawn API -//! // is used -//! // same configuration -//! let child2 = git.command() -//! .args(["clone", "https://example2.git", "dest2", "--progress"]) -//! .stdin_null() -//! .stdoe(cu::pio::spinner("cloning example2").info()) -//! .co_spawn().await?.0; -//! -//! child1.co_wait_nz().await?; -//! child2.co_wait_nz().await?; -//! -//! Ok(()) -//! } -//! ``` - -pub use crate::async_::{ - AbortHandle, Handle, Pool, RobustAbortHandle, RobustHandle, Set, pool, run, set, set_flatten, - spawn, -}; - -#[cfg(not(feature = "coroutine-heavy"))] -pub use crate::async_::block; -#[cfg(feature = "coroutine-heavy")] -pub use crate::async_::spawn_blocking; diff --git a/packages/copper/src/async_/mod.rs b/packages/copper/src/co/co_util.rs similarity index 83% rename from packages/copper/src/async_/mod.rs rename to packages/copper/src/co/co_util.rs index 0bbae9c..753145c 100644 --- a/packages/copper/src/async_/mod.rs +++ b/packages/copper/src/co/co_util.rs @@ -1,10 +1,3 @@ -mod pool; -pub use pool::*; -mod runtime; -pub use runtime::*; -mod handle; -pub use handle::*; - /// return Ok if the error is abort pub(crate) fn handle_join_error(e: tokio::task::JoinError) -> crate::Result<()> { let e = match e.try_into_panic() { diff --git a/packages/copper/src/async_/handle.rs b/packages/copper/src/co/handle.rs similarity index 97% rename from packages/copper/src/async_/handle.rs rename to packages/copper/src/co/handle.rs index 5590bd6..f920ae6 100644 --- a/packages/copper/src/async_/handle.rs +++ b/packages/copper/src/co/handle.rs @@ -3,6 +3,8 @@ use std::sync::atomic::{AtomicU8, Ordering}; use tokio::task::{JoinError, JoinHandle}; +use crate::co::{co_util, runtime}; + /// Join handle for async task /// /// This is a wrapper around `tokio`'s `JoinHandle` type. @@ -43,7 +45,7 @@ impl Handle { /// Use [`co_join().await`](`Self::co_join`) instead. #[inline] pub fn join(self) -> crate::Result { - Self::handle_error(super::foreground().block_on(self.0)) + Self::handle_error(runtime::foreground().block_on(self.0)) } /// Wait for the task asynchronously @@ -67,7 +69,7 @@ impl Handle { /// Use [`co_join_maybe_aborted().await`](`Self::co_join_maybe_aborted`) instead. #[inline] pub fn join_maybe_aborted(self) -> crate::Result> { - Self::handle_error_maybe_aborted(super::foreground().block_on(self.0)) + Self::handle_error_maybe_aborted(runtime::foreground().block_on(self.0)) } /// Like [`co_join`](Self::co_join), but returns `None` if the task was aborted. @@ -90,7 +92,7 @@ impl Handle { Ok(x) => return Ok(Some(x)), Err(e) => e, }; - super::handle_join_error(e)?; + co_util::handle_join_error(e)?; Ok(None) } } @@ -249,7 +251,7 @@ impl RobustHandle { /// Use [`co_join_maybe_aborted_robust().await`](`Self::co_join_maybe_aborted_robust`) instead. pub fn join_maybe_aborted_robust(self) -> crate::Result>> { Self::handle_error_maybe_aborted_robust( - super::foreground().block_on(self.inner.0), + runtime::foreground().block_on(self.inner.0), &self.aborted, ) } @@ -281,7 +283,7 @@ impl RobustHandle { if Self::check_aborted(aborted) { return Ok(Err(None)); } - super::handle_join_error(e)?; + co_util::handle_join_error(e)?; Ok(Err(None)) } diff --git a/packages/copper/src/co/mod.rs b/packages/copper/src/co/mod.rs new file mode 100644 index 0000000..d1e8841 --- /dev/null +++ b/packages/copper/src/co/mod.rs @@ -0,0 +1,89 @@ +//! # Coroutines (Async) +//! +//! `cu` is designed to have flexible coroutine handling. For example, consider these program styles: +//! - everything being `async` - typically involving both CPU-bound +//! work and IO work interwined. Can take advantage of multiple background threads. +//! - Some IO heavy work that doesn't really involve CPU - for example, +//! spawning compiler processes and wait for them, or spawning network requests. +//! Usually won't have significant performance benefit from having multiple background threads. +//! - Heavy CPU work that only has a little IO. Using `async` usually has very little +//! benefit. (Would probably use something like `rayon` to get parallelism). +//! +//! You pick the style you want. +//! +//! The async runtime being used under the hood is [`tokio`](https://docs.rs/tokio). +//! There are 2 feature flags you can choose from: `coroutine` and `coroutine-heavy`. +//! `coroutine` uses one foreground (current-thread) tokio runtime and one background thread to +//! drive IO tasks. `coroutine-heavy` does not have a current-thread runtime - everything +//! is done on the multi-threaded, background runtime. +//! +//! # Async entry point +//! +//! To make your entire program async with [`cli`](module@crate::cli), +//! simply make the `main` function `async. +//! +//! ```rust +//! use std::time::Duration; +//! # use pistonite_cu as cu; +//! #[cu::cli] +//! async fn main(_: cu::cli::Flags) -> cu::Result<()> { +//! cu::info!("doing some work"); +//! tokio::time::sleep(Duration::from_millis(100)).await; +//! cu::info!("done"); +//! Ok(()) +//! } +//! ``` +//! +//! When using `coroutine`, the main future will be spawned onto the current-thread +//! runtime (so the main thread is still driving it). When using `coroutine-heavy`, +//! the main future is spawned onto the background runtime, and the main thread +//! waits until the future is completed. +//! +//! # Coroutines used internally and `co_*` APIs +//! Some `cu` functions use coroutines internally behind "synchronous" APIs, +//! allowing seamless integration from a synchronous context. +//! +//! For example, when spawning child processes, an async task processes +//! IO from the child and streams results to the main thread. This allows +//! for a clean API to for example, read child's output line-by-line. +//! +//! However, there is an important catch - it is crucial that we never block +//! an async runtime. This means to wait for a future: +//! - If we are not in an async runtime, we can enter the async runtime by +//! calling an entry point to the runtime (a.k.a `block`), to block +//! the current thread while letting the runtime run until the future is finished. +//! - If we are already in an async runtime, we must call `.await` instead +//! of block. Otherwise, either the entire runtime will block and may deadlock, +//! or tokio will detect it and panic. +//! +//! This is why the APIs that use `coroutine` under the hood will have +//! another version with a `co_*` prefix. For example, `spawn` and `co_spawn`. +//! You MUST use `.spawn()?` if not in an async runtime, and `.co_spawn().await?` +//! in an async runtime. Note that calling `.co_spawn()` while not in an async runtime +//! is also not allowed, because tokio will assume a runtime is active and panic +//! if not. +//! +//! # Advanced Usage +//! If additional functionality from `tokio` is needed (not already provided by re-exports), +//! then you can add `tokio` to `Cargo.toml`: +//! ```toml +//! [dependencies] +//! tokio = "1" +//! ``` + +// re-exports +pub use tokio::{join, select, try_join}; + +mod runtime; +#[cfg(not(feature = "coroutine-heavy"))] +pub use runtime::block; +#[cfg(feature = "coroutine-heavy")] +pub use runtime::spawn_blocking; +pub use runtime::{run, spawn}; + +mod pool; +pub use pool::{Pool, Set, pool, set, set_flatten}; + +mod handle; +pub use handle::{AbortHandle, Handle, RobustAbortHandle, RobustHandle}; +mod co_util; diff --git a/packages/copper/src/async_/pool.rs b/packages/copper/src/co/pool.rs similarity index 97% rename from packages/copper/src/async_/pool.rs rename to packages/copper/src/co/pool.rs index a790d73..3326335 100644 --- a/packages/copper/src/async_/pool.rs +++ b/packages/copper/src/co/pool.rs @@ -2,7 +2,7 @@ use std::sync::{Arc, Weak}; use tokio::sync::Semaphore; -use super::{AbortHandle, Handle}; +use crate::co::{AbortHandle, Handle, co_util, runtime}; /// Create a new [`Pool`]. /// @@ -227,7 +227,7 @@ impl Set { drop(abort_handle); result }, - super::background().handle(), + runtime::background().handle(), ); } @@ -246,7 +246,7 @@ impl Set { pub async fn next(&mut self) -> Option> { let result = self.join_set.join_next().await?; match result { - Err(join_error) => match super::handle_join_error(join_error) { + Err(join_error) => match co_util::handle_join_error(join_error) { Err(e) => Some(Err(e)), Ok(_) => Some(Err(crate::fmterr!("aborted"))), }, @@ -263,6 +263,6 @@ impl Set { /// Use [`next().await`](`Self::next`) instead. #[inline] pub fn block(&mut self) -> Option> { - super::foreground().block_on(async move { self.next().await }) + runtime::foreground().block_on(async move { self.next().await }) } } diff --git a/packages/copper/src/async_/runtime.rs b/packages/copper/src/co/runtime.rs similarity index 87% rename from packages/copper/src/async_/runtime.rs rename to packages/copper/src/co/runtime.rs index e5b311e..2eda64c 100644 --- a/packages/copper/src/async_/runtime.rs +++ b/packages/copper/src/co/runtime.rs @@ -2,7 +2,7 @@ use std::sync::LazyLock; use tokio::runtime::{Builder, Runtime}; -use super::Handle; +use crate::co::Handle; /// the current-thread runtime #[cfg(not(feature = "coroutine-heavy"))] @@ -34,6 +34,9 @@ static BACKGROUND_RUNTIME: LazyLock = LazyLock::new(|| { /// Get a reference of a runtime that contains the current thread pub(crate) fn foreground() -> &'static Runtime { + // only use the background runtime, because + // the foreground could be a background thread, + // and blocking it would block the background runtime #[cfg(not(feature = "coroutine-heavy"))] { &RUNTIME @@ -50,13 +53,15 @@ pub(crate) fn background() -> &'static Runtime { /// Run an async task using the current thread. /// -/// To prevent misuse, this is only available without the `coroutine-heavy` -/// feature. Consider this entry point to some async procedure, if most of +/// Consider using this as an entry point to some async procedure, if most of /// your program is sync. /// +/// To prevent misuse, this is only available without the `coroutine-heavy` +/// feature. +/// /// Use [`spawn`] or [`run`] to run async tasks using the background thread(s) /// in both light and heavy async use cases. -#[inline] +#[inline(always)] #[cfg(not(feature = "coroutine-heavy"))] pub fn block(future: F) -> F::Output where @@ -66,7 +71,7 @@ where } /// Spawn a task onto the background runtime -#[inline] +#[inline(always)] pub fn spawn(future: F) -> Handle where F: Future + Send + 'static, @@ -79,7 +84,7 @@ where /// /// Since the light context only has one background thread, /// this is only enabled in heavy context to prevent misuse. -#[inline] +#[inline(always)] #[cfg(feature = "coroutine-heavy")] pub fn spawn_blocking(func: F) -> Handle where @@ -90,7 +95,7 @@ where } /// Run an async task using the background runtime -#[inline] +#[inline(always)] pub fn run(future: F) -> F::Output where F: Future, diff --git a/packages/copper/src/errhand.rs b/packages/copper/src/errhand.rs new file mode 100644 index 0000000..8734bf3 --- /dev/null +++ b/packages/copper/src/errhand.rs @@ -0,0 +1,237 @@ +pub use anyhow::{Context, Error, Ok, Result, anyhow as fmterr, bail}; + +/// # Error Handling +/// *Does not require any feature flag. Please make sure to sponsor [David Tolnay](https://github.com/dtolnay) if you depend heavily on his work +/// on the Rust ecosystem.* +/// +/// Most of the error handling stuff is re-exported from [`anyhow`](https://docs.rs/anyhow), +/// which is a crate that makes tracing and formatting error messages SUPER easy. +/// This is the easiest way to quickly write debuggable program without structured error. +/// Structured error types would be more useful if you are making a library though. +/// +/// The traits required for error handling are included in the prelude import +/// ```rust +/// # use pistonite_cu as cu; +/// use cu::pre::*; +/// ``` +/// +/// Here are the most commonly used `anyhow` re-exports +/// - `anyhow::Result` is `cu::Result` +/// - `anyhow::bail!` is `cu::bail!` +/// - `anyhow::Ok` is `cu::Ok` +/// +/// Here are custom utilities from `cu` that integrates with `anyhow` +/// - `cu::check!` wraps `.with_context()` +/// ```rust +/// # use pistonite_cu as cu; +/// use cu::pre::*; +/// +/// fn some_fallable_func() -> cu::Result { +/// Ok("foo".to_string()) +/// } +/// fn main() -> cu::Result<()> { +/// // this input is just to show the formatting +/// let input: i32 = 42; +/// +/// let foo = cu::check!(some_fallable_func(), "failed: {input}")?; +/// // with anyhow, this would be: +/// // let foo = some_fallable_func().with_context(|| format!("failed: {input}"))?; +/// // -- much longer! +/// assert_eq!(foo, "foo"); +/// Ok(()) +/// } +/// ``` +/// - [`cu::rethrow!`](macro@crate::rethrow) is similar to `bail!`, but works with an `Error` instance at hand +/// - [`cu::unimplemented!`](macro@crate::unimplemented) +/// and [`cu::unreachable!`](macro@crate::unreachable) +/// that are similar to the std macros, but instead of `panic!`, they will `bail!` +/// - [`cu::ensure`](macro@crate::ensure) is unlike `anyhow::ensure`, that +/// it evaluates to a `Result<()>` instead of generates a return. +/// It also does not automatically generate debug information. +/// +/// Here are other `anyhow` re-exports that are less commonly used +/// - `anyhow::anyhow` is `cu::fmterr` +/// +/// Finally, if you do need to panic, [`cu::panicand`](macro@crate::panicand) +/// allows you to also log the same message so you can debug it easier. +/// +/// # Context (To `check` or not to `check`) +/// It is tricky to determine if you should wrap the result +/// with a `check!`, or just propagate it with a `?`. +/// Ultimately, there is no correct answer (you may say it is contextual). +/// Another way of phrasing the same question is if the context +/// should be added by the caller or the callee. +/// +/// The principle I personally follow is the caller should only `check!` +/// if there are additional information in the caller's context +/// that the callee does not already know. The information that both +/// the callee and caller have access to is: +/// - The function parameters +/// - The name of the call +/// - The behavior of the function +/// +/// These make up the API of the function. +/// +/// For example, I will write the following code +/// ```rust,ignore +/// # use pistonite_cu as cu; +/// use cu::pre::*; +/// +/// fn process_paths(paths: &[&str]) -> cu::Result<()> { +/// cu::info!("processing paths..."); +/// for (i, path) in paths.iter().enumerate() { +/// // here there might be some candidates for context: +/// // - "failed to save important value to '{path}'" +/// // - the callee knows the current context is saving +/// // "important value" to "path" (from function name and parameter), +/// // therefore this message does not add additional context +/// // - "process paths failed on '{path}'" +/// // - this could work in some cases, but here I know +/// // cu would already log the path if it fails +/// // - here I choose to log {i} which might help me finding the erroreous +/// // path from some kind of data set. +/// cu::check!(save_important_value_to(path), "failed to process {i}th path")?; +/// } +/// Ok(()) +/// } +/// +/// fn save_important_value_to(path: &str) -> cu::Result<()> { +/// // here, the only information I have that cu::fs::write +/// // does not have, is "important value" is "some random thing". +/// // this is not an important context to log to the error, +/// // so I choose to simply ? +/// cu::fs::write(path, "some random thing")?; +/// Ok(()) +/// } +/// ``` +/// +/// With that said, now we can introduce [`cu::context`](macro@crate::context), +/// which wraps a function and append a formatted context to it. +/// This is a double-edge sword. You could end up with unnecessarily bloated +/// error stack if you put too much context. However, +/// it can be extremely useful in a complex function with many possible error paths +/// to add context for what the overall failure is. +/// +/// If you find yourself writing the same `check!` to every invocation of some function. +/// Considering using it. However, I would not use this on any public API of your code. +#[macro_export] +macro_rules! check { + ($result:expr, $($args:tt)*) => {{ + { $result }.with_context(|| format!($($args)*)) + }}; +} + +/// Rethrow an `Err`, optionally with additional context +/// +/// This is useful if the error path requires additional handling +/// +/// Prelude import is required to bring in the Context trait. +/// +/// ```rust +/// # use pistonite_cu as cu; +/// use cu::pre::*; +/// +/// fn some_fallable_func() -> cu::Result { +/// Ok("foo".to_string()) +/// } +/// +/// fn main() -> cu::Result<()> { +/// // this input is just to show the formatting +/// let input: i32 = 42; +/// +/// let foo = match some_fallable_func() { +/// Ok(x) => x, +/// Err(e) => { +/// // supposed some additional handling is needed, +/// // like setting some error state... +/// +/// cu::rethrow!(e, "failed: {input}"); +/// } +/// }; +/// +/// assert_eq!(foo, "foo"); +/// +/// Ok(()) +/// } +/// ``` +#[macro_export] +macro_rules! rethrow { + ($result:expr) => { + return Err($result); + }; + ($result:expr, $($args:tt)*) => {{ + return Err($result).context(format!($($args)*)); + }}; +} + +/// Like `unimplemented!` in std library, but log a message +/// and return an error instead of panicking +#[macro_export] +macro_rules! unimplemented { + () => { + $crate::trace!("unexpected: not implemented reached"); + return $crate::Error::msg("not implemented"); + }; + ($($args:tt)*) => {{ + let msg = format!("{}", format_args!($(args)*)); + $crate::trace!("unexpected: not implemented reached: {msg}"); + $crate::bail!("not implemented: {msg}") + }} +} + +/// Like `unreachable!` in std library, but log a message +/// and return an error instead of panicking reached. +/// This might be less performant in release builds +#[macro_export] +macro_rules! unreachable { + () => { + $crate::trace!("unexpected: entered unreachable code"); + return $crate::Error::msg("unreachable"); + }; + ($($args:tt)*) => {{ + let msg = format!("{}", format_args!($(args)*)); + $crate::trace!("unexpected: entered unreachable code: {msg}"); + $crate::bail!("unreachable: {msg}") + }} +} + +/// Check if an expression is `true` +/// +/// Unlike `anyhow::ensure`, if the condition fail, this will generate an `Error` +/// instead of returning an error directly, so you need to add a `?`. +/// It also always include the expression stringified in the debug info. +/// However, it does not automatically parse the input and generate debug +/// info message based on that (unlike `anyhow`) +#[macro_export] +macro_rules! ensure { + ($result:expr) => {{ + if !bool::from($result) { + Err($crate::fmterr!("condition failed: `{}`", stringify!($result))) + } else { + Ok(()) + } + }}; + ($result:expr, $($args:tt)*) => {{ + if !bool::from($result) { + Err($crate::fmterr!("condition failed: `{}`: {}", stringify!($result), format_args!($($args)*))) + } else { + Ok(()) + } + }}; +} + +/// Invoke a print macro, then panic with the same message +/// +/// # Example +/// ```rust,no_run +/// # use pistonite_cu as cu; +/// cu::panicand!(error!("found {} errors", 3)); +/// ``` +#[macro_export] +macro_rules! panicand { + ($mac:ident !( $($fmt_args:tt)* )) => {{ + let s = format!($($fmt_args)*); + $crate::$mac!("{s}"); + panic!("{s}"); + }} +} diff --git a/packages/copper/src/bin.rs b/packages/copper/src/fs/bin.rs similarity index 100% rename from packages/copper/src/bin.rs rename to packages/copper/src/fs/bin.rs diff --git a/packages/copper/src/fs/dir.rs b/packages/copper/src/fs/dir.rs index e0ba814..32b9bd3 100644 --- a/packages/copper/src/fs/dir.rs +++ b/packages/copper/src/fs/dir.rs @@ -314,7 +314,7 @@ async fn co_read_dir_impl(path: &Path) -> crate::Result { pub fn rec_copy_inefficiently(from: impl AsRef, to: impl AsRef) -> crate::Result<()> { rec_copy_inefficiently_impl(from.as_ref(), to.as_ref()) } -#[crate::error_ctx("failed to copy '{}' to '{}'", from.display(), to.display())] +#[crate::context("failed to copy recursively copy '{}' to '{}'", from.display(), to.display())] fn rec_copy_inefficiently_impl(from: &Path, to: &Path) -> crate::Result<()> { crate::trace!( "rec_copy_inefficiently from='{}' to='{}'", diff --git a/packages/copper/src/fs/file.rs b/packages/copper/src/fs/file.rs index c364fdb..6bf1f8f 100644 --- a/packages/copper/src/fs/file.rs +++ b/packages/copper/src/fs/file.rs @@ -207,7 +207,7 @@ async fn co_remove_impl(path: &Path) -> crate::Result<()> { pub fn rename(from: impl AsRef, to: impl AsRef) -> crate::Result<()> { rename_impl(from.as_ref(), to.as_ref()) } -#[crate::error_ctx("failed to rename '{}' to '{}'", from.display(), to.display())] +#[crate::context("failed to rename '{}' to '{}'", from.display(), to.display())] fn rename_impl(from: &Path, to: &Path) -> crate::Result<()> { crate::trace!("rename: '{}' to '{}'", from.display(), to.display()); let Ok(from_meta) = from.metadata() else { diff --git a/packages/copper/src/fs/mod.rs b/packages/copper/src/fs/mod.rs index 97a8479..d97f873 100644 --- a/packages/copper/src/fs/mod.rs +++ b/packages/copper/src/fs/mod.rs @@ -1,3 +1,9 @@ +//! # File System Operations +//! +//! Much of this is WIP and I will fill up this documentation in the future. +//! The general principle of this is `cu::fs` functions have tracing and error +//! context built-in + mod dir; pub use dir::*; mod file; @@ -10,5 +16,6 @@ mod walk; pub use walk::*; mod glob; pub use glob::*; +pub mod bin; pub use filetime::FileTime as Time; diff --git a/packages/copper/src/lib.rs b/packages/copper/src/lib.rs index af4b0b8..85e7b44 100644 --- a/packages/copper/src/lib.rs +++ b/packages/copper/src/lib.rs @@ -1,12 +1,11 @@ +//! # Cu = Copper //! Batteries-included common utils //! //! (If you are viewing this on docs.rs, please use the [self-hosted //! version](https://cu.pistonite.dev) instead) //! -//! # Install -//! Since crates.io does not have namespaces, this crate has a prefix. -//! You should manually rename it to `cu`, as that's what the proc-macros -//! expect. +//! # Quick start +//! When installing, rename the crate to `cu` in `Cargo.toml`: //! ```toml //! # Cargo.toml //! # ... @@ -15,91 +14,124 @@ //! version = "..." # check by running `cargo info pistonite-cu` //! features = [ "full" ] # see docs //! -//! # ... //! [dependencies] +//! # ... //! ``` //! -//! # General Principal -//! `cu` tries to be as short as possible with imports. Common and misc -//! utilities are exported directly by the crate and should be used -//! as `cu::xxx` directly. Sub-functionalities are bundled when makes -//! sense, and should be called from submodules directly, like `cu::fs::xxx` -//! or `cu::co::xxx`. The submodules are usually 2-4 characters. -//! -//! The only time to use `use` to import from `cu`, is with the prelude module -//! `pre`: +//! The goal with using `cu` is use only one `use` statement: //! ```rust //! # use pistonite_cu as cu; //! use cu::pre::*; //! ``` -//! This imports traits like [`Context`] and [`PathExtension`] into scope. +//! This brings into scope a few things: +//! - Traits that are expected to be used, such as `anyhow::Context` +//! - Re-exports of modules, such as `json` if the `json` feature is enabled +//! +//! If a function or type is not included with `pre::*`, that means the canonical +//! style for using it is with the full path, for example, you would write: +//! +//! ```rust,ignore +//! # use pistonite_cu as cu; +//! use cu::pre::*; +//! +//! fn read_file() -> cu::Result<()> { +//! cu::fs::read_string("foo/bar.txt")?; +//! cu::info!("successfully read file"); +//! Ok(()) +//! } +//! ``` //! -//! # Feature Reference: -//! - `cli`, `print`, `prompt`: -//! See [`cli`](module@cli). Note that logging is still available without any feature flag. -//! - `coroutine` and `coroutine-heavy`: -//! Enables `async` and integration with `tokio`. See [`cu::co`](module@co). -//! - `fs`: Enables file system utils. See [`cu::fs`](module@fs) and [`cu::bin`](module@bin). -//! - `process`: Enables utils spawning child process. See [`Command`]. -//! - `parse`, `json`, `yaml`, `toml`: -//! Enable parsing utilities, and additional support for common formats. See -//! [`Parse`](trait@Parse). +//! instead of the below: +//! ```rust,ignore +//! # use pistonite_cu as cu; +//! use cu::pre::*; +//! use cu::{Result, fs, info}; +//! // ^ don't include extra uses! +//! // the biggest disadvantage of this is +//! // it's easy to confuse with types in the standard library +//! +//! fn read_file() -> Result<()> { +//! fs::read_string("foo/bar.txt")?; +//! info!("successfully read file"); +//! Ok(()) +//! } +//! ``` +//! +//! # Quick Reference +//! - [Error Handling](macro@crate::check) (via [`anyhow`](https://docs.rs/anyhow)) +//! - [Logging](mod@crate::lv) (via [`log`](https://docs.rs/log)) +//! - [Printing and Command Line Interface](mod@crate::cli) (CLI arg parsing via +//! [`clap`](https://docs.rs/clap)) +//! - [Progress Bars](fn@crate::progress) +//! - [Prompting](macro@crate::prompt) +//! - [Coroutines (Async)](mod@crate::co) (via [`tokio`](https://docs.rs/tokio)) +//! - [File System Paths and Strings](trait@crate::str::PathExtension) +//! - [File System Operations](mod@crate::fs) +//! - [Binary Path Registry](mod@crate::fs::bin) +//! - [Spawning Child Processes](crate::Command) +//! - [Parsing](trait@Parse) (via [`serde`](https://docs.rs/serde)) +//! - Derive Macros: the `derive` feature, via [`derive_more`](https://docs.rs/derive_more) #![cfg_attr(any(docsrs, feature = "nightly"), feature(doc_cfg))] +// for macros extern crate self as cu; -#[cfg(feature = "process")] -mod process; -#[cfg(feature = "process")] -pub use process::{Child, Command, CommandBuilder, Spawn, color_flag, color_flag_eq, pio}; -#[cfg(all(feature = "process", feature = "print"))] -pub use process::{width_flag, width_flag_eq}; +// --- Basic stuff (no feature needed) --- +pub mod str; +pub use str::{ByteFormat, ZString}; -#[cfg(feature = "fs")] -pub mod bin; -#[cfg(feature = "fs")] -#[doc(inline)] -pub use bin::which; +mod env_var; +pub use env_var::*; +mod atomic; // Atomic helpers +pub use atomic::*; +mod misc; // other stuff that doesn't have a place +pub use misc::*; -/// File System utils (WIP) -#[cfg(feature = "fs")] -pub mod fs; +// --- Error Handling (no feature needed) --- +mod errhand; +pub use errhand::*; +pub use pistonite_cu_proc_macros::context; -/// Path utils -#[cfg(feature = "fs")] -mod path; -#[cfg(feature = "fs")] -pub use path::{PathExtension, PathExtensionOwned}; +// --- Logging (no feature needed) --- +pub mod lv; +pub use lv::{debug, error, info, trace, warn}; -#[cfg(feature = "cli")] +// --- Command Line Interface (print/cli/prompt/prompt-password feature) --- +#[cfg(feature = "print")] pub mod cli; +#[cfg(feature = "prompt-password")] +pub use cli::password_chars_legal; +#[cfg(feature = "print")] +pub use cli::{ProgressBar, ProgressBarBuilder, progress}; #[cfg(feature = "cli")] pub use pistonite_cu_proc_macros::cli; -#[cfg(feature = "coroutine")] -mod async_; +// --- Async (coroutine/coroutine-heavy) --- /// Alias for a boxed future pub type BoxedFuture = std::pin::Pin + Send + 'static>>; #[cfg(feature = "coroutine")] pub mod co; +#[cfg(feature = "coroutine")] +pub use co::{join, select, try_join}; -/// Low level printing utils and integration with log and clap -#[cfg(feature = "print")] -mod print; -#[cfg(feature = "prompt-password")] -pub use print::check_password_legality; -#[cfg(feature = "print")] -pub use print::{ - ProgressBar, ZeroWhenDropString, init_print_options, log_init, progress_bar, progress_bar_lowp, - progress_unbounded, progress_unbounded_lowp, set_thread_print_name, term_width, - term_width_height, term_width_or_max, -}; +// --- File System --- +#[cfg(feature = "fs")] +pub mod fs; +#[cfg(feature = "fs")] +pub use fs::bin; +#[cfg(feature = "fs")] +pub use fs::bin::which; -/// Printing level values -pub mod lv; -#[doc(inline)] -pub use lv::{color_enabled, disable_print_time, disable_trace_hint, log_enabled}; +// === above is refactored and documented === + +// --- Child Process --- +#[cfg(feature = "process")] +mod process; +#[cfg(feature = "process")] +pub use process::{Child, Command, CommandBuilder, Spawn, color_flag, color_flag_eq, pio}; +#[cfg(all(feature = "process", feature = "print"))] +pub use process::{width_flag, width_flag_eq}; /// Parsing utilities #[cfg(feature = "parse")] @@ -108,33 +140,15 @@ mod parse; pub use parse::*; #[cfg(feature = "parse")] pub use pistonite_cu_proc_macros::Parse; -mod env_var; -pub use env_var::*; - -// Atomic helpers -mod atomic; -pub use atomic::*; - -// other stuff that doesn't have a place -mod misc; -pub use misc::*; - -// re-exports from libraries -pub use anyhow::{Context, Error, Ok, Result, anyhow as fmterr, bail, ensure}; -pub use log::{debug, error, info, trace, warn}; -pub use pistonite_cu_proc_macros::error_ctx; -#[cfg(feature = "coroutine")] -pub use tokio::{join, try_join}; #[doc(hidden)] pub mod __priv { - #[cfg(feature = "print")] - pub use crate::print::{__print_with_level, __prompt, __prompt_yesno}; #[cfg(feature = "process")] pub use crate::process::__ConfigFn; } /// Lib re-exports +#[doc(hidden)] pub mod lib { #[cfg(feature = "cli")] pub use clap; @@ -143,20 +157,26 @@ pub mod lib { } /// Prelude imports +#[doc(hidden)] pub mod pre { pub use crate::Context as _; + pub use crate::str::{OsStrExtension as _, OsStrExtensionOwned as _}; + + #[cfg(feature = "cli")] + pub use crate::lib::clap; + + #[cfg(feature = "coroutine")] + pub use tokio::io::{AsyncBufReadExt as _, AsyncReadExt as _, AsyncWriteExt as _}; + + #[cfg(feature = "fs")] + pub use crate::str::PathExtension as _; + #[cfg(feature = "parse")] pub use crate::ParseTo as _; - #[cfg(feature = "fs")] - pub use crate::PathExtension as _; - #[cfg(feature = "fs")] - pub use crate::PathExtensionOwned as _; #[cfg(feature = "process")] pub use crate::Spawn as _; #[cfg(feature = "json")] pub use crate::json; - #[cfg(feature = "cli")] - pub use crate::lib::clap; #[cfg(feature = "toml")] pub use crate::toml; #[cfg(feature = "yaml")] @@ -173,7 +193,4 @@ pub mod pre { LowerHex as DisplayLowerHex, Octal as DisplayOctal, Pointer as DisplayPointer, UpperExp as DisplayUpperExp, UpperHex as DisplayUpperHex, }; - - #[cfg(feature = "coroutine")] - pub use tokio::io::{AsyncBufReadExt as _, AsyncReadExt as _}; } diff --git a/packages/copper/src/lv.rs b/packages/copper/src/lv.rs index 01f223c..52a05d4 100644 --- a/packages/copper/src/lv.rs +++ b/packages/copper/src/lv.rs @@ -1,16 +1,47 @@ -use std::sync::atomic::{AtomicBool, Ordering}; +//! # Logging +//! +//! *Does not require any feature flag +//! +//! The logging macros (`debug`, `info`, `trace`, `warn`, `error`) are +//! re-exported from the [`log`](https://docs.rs/log) crate and are +//! used as `cu::debug!`, `cu::info!`, etc. This means your log statements +//! are integrated into the log infrastructure when you use `cu` in a library. +//! +//! Additionally, the [`cu::fmtand!`](macro@crate::fmtand) and +//! [`cu::panicand!`](macro@crate::panicand) macros allow you +//! to log a message in additional to `format!`/`panic!`. +//! +//! When the `cli` feature is enabled, you also get log integration +//! with CLI flags and other terminal-printing features. +//! See [Command Line Interface](mod@crate::cli) -use crate::{Atomic, lv}; +pub use log::{debug, error, info, trace, warn}; -pub(crate) static PRINT_LEVEL: Atomic = Atomic::new_u8(lv::Print::Normal as u8); -pub(crate) static USE_COLOR: AtomicBool = AtomicBool::new(true); +use std::sync::atomic::{AtomicBool, Ordering}; + +use cu::Atomic; -/// Check if the logging level is enabled -pub fn log_enabled(lv: lv::Lv) -> bool { - lv.can_print(PRINT_LEVEL.get()) +/// Format and invoke a print macro +/// +/// # Example +/// ```rust +/// # use pistonite_cu as cu; +/// let x = cu::fmtand!(error!("found {} errors", 3)); +/// assert_eq!(x, "found 3 errors"); +/// ``` +#[macro_export] +macro_rules! fmtand { + ($mac:ident !( $($fmt_args:tt)* )) => {{ + let s = format!($($fmt_args)*); + $crate::$mac!("{s}"); + s + }} } -/// Get if color printing is enabled +pub(crate) static PRINT_LEVEL: Atomic = Atomic::new_u8(Print::Normal as u8); +pub(crate) static USE_COLOR: AtomicBool = AtomicBool::new(true); + +/// Get if color printing is enabled **Only works when cu::cli is being used**. pub fn color_enabled() -> bool { USE_COLOR.load(Ordering::Acquire) } @@ -22,11 +53,13 @@ static ENABLE_PRINT_TIME: AtomicBool = AtomicBool::new(true); /// /// By default, the hint is displayed if `RUST_BACKTRACE` env var is not set #[inline(always)] +#[cfg(feature = "print")] pub fn disable_trace_hint() { ENABLE_TRACE_HINT.store(false, Ordering::Release); } -/// Check if the "use -vv to display backtrace" will be printed on error +/// Check if the "use -vv to display backtrace" will be printed on error. +/// **Only works when cu::cli is being used** #[inline(always)] pub fn is_trace_hint_enabled() -> bool { ENABLE_TRACE_HINT.load(Ordering::Acquire) @@ -34,11 +67,13 @@ pub fn is_trace_hint_enabled() -> bool { /// Disable printing the time took to run the command #[inline(always)] +#[cfg(feature = "print")] pub fn disable_print_time() { ENABLE_PRINT_TIME.store(false, Ordering::Release); } -/// Check if the "finished in TIME" line will be printed on exit +/// Check if the "finished in TIME" line will be printed on exit. +/// **Only works when cu::cli is being used** #[inline(always)] pub fn is_print_time_enabled() -> bool { ENABLE_PRINT_TIME.load(Ordering::Acquire) @@ -163,18 +198,21 @@ impl From for log::LevelFilter { #[derive(Debug, Clone, Copy, PartialEq, Eq)] #[repr(u8)] pub enum Prompt { - /// Show prompts interactively + /// Show all prompts interactively Interactive, - /// Automatically answer "Yes" to all yes/no prompts, and `Auto` for regular prompts - Yes, - /// Do not allow prompts (non-interactive). Attempting to show prompt will error - No, + /// Automatically answer "Yes" to all yes/no prompts, and show other prompts interactively + YesOrInteractive, + /// Automatically answer "Yes" to all yes/no prompts, and do not allow other prompts + YesOrBlock, + /// Do not allow any type of prompts (non-interactive). Attempting to show prompt will error + Block, } impl From for Prompt { fn from(value: u8) -> Self { match value { - 1 => Self::Yes, - 2 => Self::No, + 1 => Self::YesOrInteractive, + 2 => Self::YesOrBlock, + 3 => Self::Block, _ => Self::Interactive, } } @@ -202,7 +240,18 @@ pub enum Lv { Off, } impl Lv { - /// Check if the current print level can print this message level + /// Check if the logging level is currently enabled **Only works when cu::cli is being used**. + /// + /// ```rust + /// # use pistonite_cu as cu; + /// // check that INFO level is enabled + /// assert!(cu::lv::I.enabled()); + /// ``` + #[inline(always)] + pub fn enabled(self) -> bool { + self.can_print(PRINT_LEVEL.get()) + } + /// Check if a print level can print this message level pub fn can_print(self, level: Print) -> bool { match self { Lv::Off => false, diff --git a/packages/copper/src/misc.rs b/packages/copper/src/misc.rs index 2a12698..5451fa6 100644 --- a/packages/copper/src/misc.rs +++ b/packages/copper/src/misc.rs @@ -17,19 +17,6 @@ pub fn best_effort_panic_info<'a>(payload: &'a Box) -> } } -/// Like `unimplemented!` in std library, but log a message -/// and return an error instead of panicking -#[macro_export] -macro_rules! noimpl { - () => { - $crate::bailand!(error!("not implemented")) - }; - ($($args:tt)*) => {{ - let msg = format!("not implemented: {}", format_args!($(args)*)); - $crate::bailand!(error!("{msg}")) - }} -} - /// Copy a reader to a writer. /// /// This is wrapper for `std::io::copy` with error context @@ -56,168 +43,3 @@ where .await .context("async stream copy failed") } - -/// Check a `Result`, unwrapping the value or giving it a context -/// and return the error. -/// -/// Prelude import is required to bring in the Context trait. -/// -/// ```rust -/// # use pistonite_cu as cu; -/// use cu::pre::*; -/// -/// fn some_fallable_func() -> cu::Result { -/// Ok("foo".to_string()) -/// } -/// -/// fn main() -> cu::Result<()> { -/// // this input is just to show the formatting -/// let input: i32 = 42; -/// -/// let foo = cu::check!(some_fallable_func(), "failed: {input}")?; -/// assert_eq!(foo, "foo"); -/// // also log the error as we return the Err -/// let foo = cu::check!(some_fallable_func(), error!("failed: {input}"))?; -/// assert_eq!(foo, "foo"); -/// -/// Ok(()) -/// } -/// ``` -#[macro_export] -macro_rules! check { - ($result:expr, $mac:ident !( $($args:tt)* )) => {{ - { $result }.with_context(|| $crate::fmtand!($mac!($($args)*))) - }}; - ($result:expr, $($args:tt)*) => {{ - { $result }.with_context(|| format!($($args)*)) - }}; -} - -/// Rethrow an `Err`, optionally with additional context -/// -/// This is useful if the error path requires additional handling -/// -/// Prelude import is required to bring in the Context trait. -/// -/// ```rust -/// # use pistonite_cu as cu; -/// use cu::pre::*; -/// -/// fn some_fallable_func() -> cu::Result { -/// Ok("foo".to_string()) -/// } -/// -/// fn main() -> cu::Result<()> { -/// // this input is just to show the formatting -/// let input: i32 = 42; -/// -/// let foo = match some_fallable_func() { -/// Ok(x) => x, -/// Err(e) => { -/// // supposed some additional handling is needed, -/// // like setting some error state... -/// -/// cu::rethrow!(e, "failed: {input}"); -/// } -/// }; -/// -/// assert_eq!(foo, "foo"); -/// -/// Ok(()) -/// } -/// ``` -#[macro_export] -macro_rules! rethrow { - ($result:expr) => { - return Err($result); - }; - ($result:expr, $mac:ident !( $($args:tt)* )) => {{ - return Err($result).context($crate::fmtand!($mac!($($args)*))); - }}; - ($result:expr, $($args:tt)*) => {{ - return Err($result).context(format!($($args)*)); - }}; -} - -/// Format and invoke a print macro -/// -/// # Example -/// ```rust -/// # use pistonite_cu as cu; -/// let x = cu::fmtand!(error!("found {} errors", 3)); -/// assert_eq!(x, "found 3 errors"); -/// ``` -#[macro_export] -macro_rules! fmtand { - ($mac:ident !( $($fmt_args:tt)* )) => {{ - let s = format!($($fmt_args)*); - $crate::$mac!("{s}"); - s - }} -} -/// Invoke a print macro, then bail with the same message -/// -/// # Example -/// ```rust -/// # use pistonite_cu as cu; -/// # fn main() { -/// fn fn_1() -> cu::Result<()> { -/// cu::bailand!(error!("found {} errors", 3)); -/// } -/// fn fn_2() -> cu::Result<()> { -/// cu::bailand!(warn!("warning!")); -/// } -/// assert!(fn_1().is_err()); // will also log error "found 3 errors" -/// assert!(fn_2().is_err()); // will also log warning "warning!" -/// # } -/// ``` -#[macro_export] -macro_rules! bailand { - ($mac:ident !( $($fmt_args:tt)* )) => {{ - let s = format!($($fmt_args)*); - $crate::$mac!("{s}"); - $crate::bail!(s); - }} -} - -/// Return an error, expecting the error will eventually -/// be propagated as a fatal error to the user, as an FYI. -/// -/// This means the error is an expected error, due to invalid -/// input, for example. -/// -/// This will hide the "use -vv for trace" hint message. -/// -/// ```rust -/// # use pistonite_cu as cu; -/// fn foo() -> cu::Result<()> { -/// cu::bailfyi!("input is invalid"); -/// } -/// -/// assert!(foo().is_err()); -/// // exiting will not print the "show trace hint" -/// ``` -/// -#[macro_export] -macro_rules! bailfyi { - ($($arg:tt)*) => {{ - $crate::lv::disable_trace_hint(); - $crate::bail!($($arg)*); - }} -} - -/// Invoke a print macro, then panic with the same message -/// -/// # Example -/// ```rust,no_run -/// # use pistonite_cu as cu; -/// cu::panicand!(error!("found {} errors", 3)); -/// ``` -#[macro_export] -macro_rules! panicand { - ($mac:ident !( $($fmt_args:tt)* )) => {{ - let s = format!($($fmt_args)*); - $crate::$mac!("{s}"); - panic!("{s}"); - }} -} diff --git a/packages/copper/src/print/init.rs b/packages/copper/src/print/init.rs deleted file mode 100644 index 00b7a99..0000000 --- a/packages/copper/src/print/init.rs +++ /dev/null @@ -1,145 +0,0 @@ -use std::cell::RefCell; -use std::sync::OnceLock; -use std::sync::atomic::Ordering; - -use crate::lv::{self, Lv}; - -static LOG_FILTER: OnceLock = OnceLock::new(); -/// Set the global log filter -pub(crate) fn set_log_filter(filter: env_filter::Filter) { - let _ = LOG_FILTER.set(filter); -} - -/// Shorthand to quickly setup logging. Can be useful in tests. -/// -/// "qq", "q", "v" and "vv" inputs map to corresponding print levels. Other inputs -/// are mapped to default level -pub fn log_init(lv: &str) { - let level = match lv { - "qq" => lv::Print::QuietQuiet, - "q" => lv::Print::Quiet, - "v" => lv::Print::Verbose, - "vv" => lv::Print::VerboseVerbose, - _ => lv::Print::Normal, - }; - init_print_options(lv::Color::Auto, level, Some(lv::Prompt::No)); -} - -/// Set global print options. This is usually called from clap args -/// -/// If prompt option is `None`, it will be `Interactive` unless env var `CI` is `true` or `1`, in which case it becomes `No`. -/// Prompt option is ignored unless `prompt` feature is enabled -pub fn init_print_options(color: lv::Color, level: lv::Print, prompt: Option) { - let log_level = if let Ok(value) = std::env::var("RUST_LOG") - && !value.is_empty() - { - let mut builder = env_filter::Builder::new(); - let filter = builder.parse(&value).build(); - let log_level = filter.filter(); - set_log_filter(filter); - log_level.max(level.into()) - } else { - level.into() - }; - log::set_max_level(log_level); - let use_color = color.is_colored_for_stdout(); - lv::USE_COLOR.store(use_color, Ordering::Release); - if let Ok(mut printer) = super::PRINTER.lock() { - printer.set_colors(use_color); - } - #[cfg(feature = "prompt")] - { - let prompt = match prompt { - Some(x) => x, - None => { - let is_ci = std::env::var("CI") - .map(|mut x| { - x.make_ascii_lowercase(); - matches!(x.trim(), "true" | "1") - }) - .unwrap_or_default(); - if is_ci { - lv::Prompt::No - } else { - lv::Prompt::Interactive - } - } - }; - super::PROMPT_LEVEL.set(prompt) - } - #[cfg(not(feature = "prompt"))] - { - let _ = prompt; - super::PROMPT_LEVEL.set(lv::Prompt::No); - } - - lv::PRINT_LEVEL.set(level); - struct LogImpl; - impl log::Log for LogImpl { - fn enabled(&self, metadata: &log::Metadata) -> bool { - match LOG_FILTER.get() { - Some(filter) => filter.enabled(metadata), - None => Lv::from(metadata.level()).can_print(lv::PRINT_LEVEL.get()), - } - } - - fn log(&self, record: &log::Record) { - if !self.enabled(record.metadata()) { - return; - } - let typ: Lv = record.level().into(); - let message = if typ == Lv::Trace { - // enable source location logging in trace messages - let mut message = String::new(); - message.push('['); - if let Some(p) = record.module_path() { - // aliased crate, use the shorthand - if let Some(rest) = p.strip_prefix("pistonite_") { - message.push_str(rest); - } else { - message.push_str(p); - } - message.push(' '); - } - if let Some(f) = record.file() { - let name = match f.rfind(['/', '\\']) { - None => f, - Some(i) => &f[i + 1..], - }; - message.push_str(name); - } - if let Some(l) = record.line() { - message.push(':'); - message.push_str(&format!("{l}")); - } - if message.len() > 1 { - message += "] "; - } else { - message.clear(); - } - - use std::fmt::Write; - let _: Result<_, _> = write!(&mut message, "{}", record.args()); - message - } else { - record.args().to_string() - }; - if let Ok(mut printer) = super::PRINTER.lock() { - printer.print_message(typ, &message); - } - } - - fn flush(&self) {} - } - - let _ = log::set_logger(&LogImpl); -} - -thread_local! { - pub(crate) static THREAD_NAME: RefCell> = const { RefCell::new(None) }; -} - -/// Set the name to show up in messages printed by the current thread -pub fn set_thread_print_name(name: &str) { - THREAD_NAME.with_borrow_mut(|x| *x = Some(name.to_string())) -} diff --git a/packages/copper/src/print/mod.rs b/packages/copper/src/print/mod.rs deleted file mode 100644 index bb23836..0000000 --- a/packages/copper/src/print/mod.rs +++ /dev/null @@ -1,18 +0,0 @@ -mod init; - -pub use init::*; -pub(crate) mod ansi; -mod printer; -pub(crate) mod utf8; -pub use printer::*; -mod format; -pub use format::*; -mod progress; -pub use progress::*; - -mod prompt; -pub use prompt::*; -#[cfg(feature = "prompt-password")] -mod prompt_password; -#[cfg(feature = "prompt-password")] -pub use prompt_password::check_password_legality; diff --git a/packages/copper/src/print/progress.rs b/packages/copper/src/print/progress.rs deleted file mode 100644 index 67dfada..0000000 --- a/packages/copper/src/print/progress.rs +++ /dev/null @@ -1,432 +0,0 @@ -use std::sync::{Arc, Mutex}; -use std::time::{Duration, Instant}; - -use super::ansi; - -/// Update a progress bar -/// -/// # Examples -/// ```rust,no_run -/// # use pistonite_cu as cu; -/// let bar = cu::progress_bar(10, "10 steps"); -/// // update the current count and message -/// let i = 1; -/// cu::progress!(&bar, i, "doing step {i}"); -/// // update the current count without changing message -/// cu::progress!(&bar, 2); -/// // update the message without changing count (or the bar is unbounded) -/// cu::progress!(&bar, (), "doing the thing"); -/// ``` -#[macro_export] -macro_rules! progress { - ($bar:expr, $current:expr) => { - $crate::ProgressBar::set($bar, $current, None); - }; - ($bar:expr, (), $($fmt_args:tt)*) => {{ - let message = format!($($fmt_args)*); - $crate::ProgressBar::set_message($bar, message); - }}; - ($bar:expr, $current:expr, $($fmt_args:tt)*) => {{ - let message = format!($($fmt_args)*); - $crate::ProgressBar::set($bar, $current, Some(message)); - }}; -} - -/// Signify the progress bar is done, with a custom done message -/// -/// # Examples -/// ```rust,no_run -/// # use pistonite_cu as cu; -/// let bar = cu::progress_bar(10, "10 steps"); -/// cu::progress!(&bar, 1, "doing step {}", 1); -/// cu::progress_done!(&bar, "this is {}", "done!"); -/// ``` -#[macro_export] -macro_rules! progress_done { - ($bar:expr, $($fmt_args:tt)*) => {{ - let message = format!($($fmt_args)*); - $crate::ProgressBar::set_done_message($bar, message); - }}; -} - -/// Create a progress bar -pub fn progress_bar(total: usize, message: impl Into) -> Arc { - let bar = Arc::new(ProgressBar::new(true, total, message.into())); - if let Ok(mut printer) = super::PRINTER.lock() { - printer.add_progress_bar(&bar); - } - bar -} - -/// Create a progress bar that doesn't print a done message -pub fn progress_bar_lowp(total: usize, message: impl Into) -> Arc { - let bar = Arc::new(ProgressBar::new(false, total, message.into())); - if let Ok(mut printer) = super::PRINTER.lock() { - printer.add_progress_bar(&bar); - } - bar -} - -/// Create a progress bar that doesn't display the current/total -/// -/// This is equipvalent to calling `progress_bar` with a total of 0 -pub fn progress_unbounded(message: impl Into) -> Arc { - progress_bar(0, message) -} - -/// Create a progress bar that doesn't display the current/total, and disappears -/// after done -/// -/// This is equipvalent to calling `progress_bar` with a total of 0 -pub fn progress_unbounded_lowp(message: impl Into) -> Arc { - progress_bar_lowp(0, message) -} - -/// Handle for a progress bar. -/// -/// The [`progress`](crate::progress) macro is used to update -/// the bar using a handle -pub struct ProgressBar { - print_done: bool, - inner: Mutex, -} -impl Drop for ProgressBar { - fn drop(&mut self) { - let (current, total, message, done_message) = { - match self.inner.lock() { - Ok(mut bar) => ( - bar.current, - bar.total, - std::mem::take(&mut bar.prefix), - std::mem::take(&mut bar.done_message), - ), - Err(_) => (0, 0, String::new(), None), - } - }; - let handle = if let Ok(mut x) = super::PRINTER.lock() { - if self.print_done { - let is_progress_complete = current >= total; - match done_message { - None => { - x.print_bar_done( - &format_bar_done(current, total, &message), - is_progress_complete, - ); - } - Some(message) => { - x.print_bar_done( - &format_bar_done_custom(current, total, &message), - is_progress_complete, - ); - } - } - } - x.take_print_task_if_should_join() - } else { - None - }; - if let Some(x) = handle { - let _: Result<(), _> = x.join(); - } - } -} -impl ProgressBar { - fn new(print_done: bool, total: usize, prefix: String) -> Self { - Self { - print_done, - inner: Mutex::new(ProgressBarState::new(total, prefix)), - } - } - /// Set the counter and message of the progress bar. - /// - /// Typically, this is done throught the [`cu::progress`](crate::progress) - /// macro instead of calling this directly - pub fn set(self: &Arc, current: usize, message: Option) { - if let Ok(mut bar) = self.inner.lock() { - bar.current = current; - if let Some(x) = message { - bar.message = x; - } - } - } - /// Set the message of the progress bar, without changing counter. - /// - /// Typically, this is done throught the [`cu::progress`](crate::progress) - /// macro instead of calling this directly - pub fn set_message(self: &Arc, message: String) { - if let Ok(mut bar) = self.inner.lock() { - bar.message = message; - } - } - /// Set the total counter. This can be used in cases where the total - /// count isn't known from the beginning. - pub fn set_total(self: &Arc, total: usize) { - if let Ok(mut bar) = self.inner.lock() { - bar.set_total(total); - } - } - - /// Override the message printed when done. - /// - /// Typically, this is done throught the [`cu::progress_done`](crate::progress_done) - /// macro instead of calling this directly - pub fn set_done_message(self: &Arc, message: String) { - if let Ok(mut bar) = self.inner.lock() { - bar.current = bar.total; - bar.done_message = Some(message); - } - } - pub(crate) fn format( - &self, - width: usize, - now: Instant, - tick: u32, - tick_interval: Duration, - out: &mut String, - temp: &mut String, - ) { - if let Ok(mut bar) = self.inner.lock() { - bar.format(width, now, tick, tick_interval, out, temp) - } - } -} - -/// Progress bar state -struct ProgressBarState { - /// Total count, or 0 for unbounded - total: usize, - /// Current count, has no meaning for unbounded - current: usize, - /// Current when we last estimated ETA - last_eta_current: usize, - /// Tick when we last estimated ETA - last_eta_tick: u32, - /// Last calculation - previous_eta: f64, - /// If ETA should be shown, we only show if it's reasonably accurate - should_show_eta: bool, - /// Prefix to display, usually indicating what the progress bar is for - prefix: String, - /// Message to display, usually indicating what the current action is - message: String, - /// If bounded, used for estimating the ETA - started: Instant, - /// If set, print this message when done instead of the default done message - done_message: Option, -} - -impl ProgressBarState { - pub(crate) fn new(total: usize, prefix: String) -> Self { - Self { - total, - current: 0, - last_eta_current: 0, - last_eta_tick: 0, - previous_eta: 0f64, - should_show_eta: false, - started: Instant::now(), - prefix, - message: String::new(), - done_message: None, - } - } - pub(crate) fn set_total(&mut self, total: usize) { - self.total = total; - self.current = self.current.min(total); - } - pub(crate) fn is_unbounded(&self) -> bool { - self.total == 0 - } - /// Format the progress bar, adding at most `width` bytes to the buffer, - /// not including a newline - pub(crate) fn format( - &mut self, - mut width: usize, - now: Instant, - tick: u32, - tick_interval: Duration, - out: &mut String, - temp: &mut String, - ) { - use std::fmt::Write; - // format: [current/total] prefix: DD.DD% ETA SS.SSs message - match width { - 0 => return, - 1 => { - out.push('.'); - return; - } - 2 => { - out.push_str(".."); - return; - } - 3 => { - out.push_str("..."); - return; - } - 4 => { - out.push_str("[..]"); - return; - } - _ => {} - } - temp.clear(); - if !self.is_unbounded() { - if write!(temp, "{}/{}", self.current, self.total).is_err() { - temp.clear(); - } - // .len() is safe because / and numbers have the same byte size and width - // -2 is safe because width > 4 here - if temp.len() > width - 2 { - out.push('['); - for _ in 0..(width - 2) { - out.push('.'); - } - out.push(']'); - return; - } - - width -= 2; - width -= temp.len(); - out.push('['); - out.push_str(temp); - out.push(']'); - } - if width > 0 { - out.push(' '); - width -= 1; - } - for (c, w) in ansi::with_width(self.prefix.chars()) { - if w > width { - break; - } - width -= w; - out.push(c); - } - if !self.is_unbounded() && self.current > 0 { - let start = self.started; - let elapsed = (now - start).as_secs_f64(); - // show percentage/ETA if the progress takes more than 2s - if elapsed > 2f64 && self.current <= self.total { - // percentage - // : DD.DD% or : 100% - if self.current == self.total { - if self.prefix.is_empty() { - if width >= 4 { - width -= 4; - out.push_str("100%"); - } - } else { - if width >= 6 { - width -= 6; - out.push_str(": 100%"); - } - } - } else { - let percentage = self.current as f32 * 100f32 / self.total as f32; - temp.clear(); - if self.prefix.is_empty() { - if write!(temp, "{percentage:.2}%").is_err() { - temp.clear(); - } - } else { - if write!(temp, ": {percentage:.2}%").is_err() { - temp.clear(); - } - } - if width >= temp.len() { - width -= temp.len(); - out.push_str(temp); - } - } - // ETA SS.SSs - let secs_per_unit = elapsed / self.current as f64; - let mut eta = secs_per_unit * (self.total - self.current) as f64; - if self.current == self.last_eta_current { - // subtract time passed since updating to this step - let elapased_since_current = - (tick_interval * (tick - self.last_eta_tick)).as_secs_f64(); - if elapased_since_current > eta { - self.last_eta_current = self.current; - self.last_eta_tick = tick; - } - eta = (eta - elapased_since_current).max(0f64); - // only start showing ETA if it's reasonably accurate - if !self.should_show_eta - && eta < self.previous_eta - tick_interval.as_secs_f64() - { - self.should_show_eta = true; - } - self.previous_eta = eta; - } else { - self.last_eta_current = self.current; - self.last_eta_tick = tick; - } - if self.should_show_eta { - if width > 0 { - out.push(' '); - width -= 1; - } - temp.clear(); - if write!(temp, "ETA {eta:.2}s;").is_err() { - temp.clear(); - } - if width >= temp.len() { - width -= temp.len(); - out.push_str(temp); - } - } - } else { - if !self.prefix.is_empty() && !self.message.is_empty() && width > 0 { - out.push(':'); - width -= 1; - } - } - if width > 0 { - out.push(' '); - width -= 1; - } - } else { - if !self.prefix.is_empty() && !self.message.is_empty() && width > 1 { - out.push_str(": "); - width -= 2; - } - } - for (c, w) in ansi::with_width(self.message.chars()) { - if w > width { - break; - } - width -= w; - out.push(c); - } - } -} - -fn format_bar_done(current: usize, total: usize, message: &str) -> String { - if total == 0 { - if message.is_empty() { - "\u{283f}] done".to_string() - } else { - format!("\u{283f}] {message}: done") - } - } else { - let done_word = if current >= total { - "done" - } else { - "interrupted" - }; - if message.is_empty() { - format!("\u{283f}][{current}/{total}] {done_word}") - } else { - format!("\u{283f}][{current}/{total}] {message}: {done_word}") - } - } -} - -fn format_bar_done_custom(current: usize, total: usize, message: &str) -> String { - if total == 0 { - format!("\u{283f}] {message}") - } else { - format!("\u{283f}][{current}/{total}] {message}") - } -} diff --git a/packages/copper/src/print/prompt.rs b/packages/copper/src/print/prompt.rs deleted file mode 100644 index 8397e83..0000000 --- a/packages/copper/src/print/prompt.rs +++ /dev/null @@ -1,231 +0,0 @@ -use crate::{Atomic, Context as _}; - -use crate::lv; -/// Show a Yes/No prompt -/// -/// Return `true` if the answer is Yes. Return an error if prompt is not allowed -/// ```rust,ignore -/// if cu::yesno!("do you want to continue?")? { -/// cu::info!("user picked yes"); -/// } -/// ``` -#[cfg(feature = "prompt")] -#[macro_export] -macro_rules! yesno { - ($($fmt_args:tt)*) => {{ - $crate::__priv::__prompt_yesno(format_args!($($fmt_args)*)) - }} -} - -/// Show a prompt -/// -/// Use the `prompt-password` feature and [`prompt_password!`](crate::prompt_password) macro -/// if prompting for a password, which will hide user's input from the console -/// -/// ```rust,ignore -/// let name = cu::prompt!("please enter your name")?; -/// cu::info!("user entered: {name}"); -/// ``` -#[cfg(feature = "prompt")] -#[macro_export] -macro_rules! prompt { - ($($fmt_args:tt)*) => {{ - $crate::__priv::__prompt(format_args!($($fmt_args)*), false).map(|x| x.to_string()) - }} -} - -/// Show a password prompt -/// -/// The console will have inputs hidden while user types, and the returned -/// value is a [`ZeroWhenDropString`](crate::ZeroWhenDropString) -/// -/// ```rust,ignore -/// let password = cu::prompt_password!("please enter your password")?; -/// cu::info!("user entered: {password}"); -/// ``` -#[cfg(feature = "prompt-password")] -#[macro_export] -macro_rules! prompt_password { - ($($fmt_args:tt)*) => {{ - $crate::__priv::__prompt(format_args!($($fmt_args)*), true) - }} -} - -/// Show a password prompt and loops until a legal password is accepted. -/// -/// Use this when prompting the user to set a password. -/// -/// The console will have inputs hidden while user types, and the returned -/// value is a [`ZeroWhenDropString`](crate::ZeroWhenDropString) -/// -/// Legal password must be non-empty, and contains only alphanumeric characters, or selected ascii -/// special characters. -/// -/// ```rust,ignore -/// let password = cu::prompt_legal_password!("please enter your password")?; -/// cu::info!("user entered: {password}"); -/// ``` -#[cfg(feature = "prompt-password")] -#[macro_export] -macro_rules! prompt_legal_password { - ($($fmt_args:tt)*) => {{ - loop { - let p = $crate::__priv::__prompt(format_args!($($fmt_args)*), true)?; - match $crate::check_password_legality(&*p) { - Ok(()) => break $crate::Ok(p), - Err(e) => { - $crate::error!("{e}"); - ::std::mem::drop(p); - } - } - } - }} -} - -pub(crate) static PROMPT_LEVEL: Atomic = - Atomic::new_u8(lv::Prompt::Interactive as u8); - -pub fn __prompt_yesno(message: std::fmt::Arguments<'_>) -> crate::Result { - match PROMPT_LEVEL.get() { - lv::Prompt::Interactive => {} - lv::Prompt::Yes => return Ok(true), - lv::Prompt::No => { - crate::bailand!(error!( - "prompt not allowed in non-interactive mode: {message}" - )); - } - } - - let message = format!("{message} [y/n]"); - let _scope = PromptJoinScope; - loop { - let recv = { - let Ok(mut printer) = super::PRINTER.lock() else { - crate::bailand!(error!("prompt failed: global print lock poisoned")); - }; - printer.show_prompt(&message, false) - }; - let result = recv - .recv() - .with_context(|| format!("recv error while showing the prompt: {message}"))?; - match result { - Err(e) => { - Err(e).context(format!("io error while showing the prompt: {message}"))?; - } - Ok(mut x) => { - x.make_ascii_lowercase(); - match x.trim() { - "y" | "yes" => return Ok(true), - "n" | "no" => return Ok(false), - _ => {} - } - } - } - crate::error!("please enter yes or no"); - } -} - -pub fn __prompt( - message: std::fmt::Arguments<'_>, - is_password: bool, -) -> crate::Result { - if let lv::Prompt::No = PROMPT_LEVEL.get() { - crate::bailand!(error!( - "prompt not allowed in non-interactive mode: {message}" - )); - } - let message = format!("{message}"); - let result = { - let _scope = PromptJoinScope; - let recv = { - let Ok(mut printer) = super::PRINTER.lock() else { - crate::bailand!(error!("prompt failed: global print lock poisoned")); - }; - printer.show_prompt(&message, is_password) - }; - recv.recv() - .with_context(|| format!("recv error while showing the prompt: {message}"))? - }; - - result.with_context(|| format!("io error while showing the prompt: {message}")) -} - -struct PromptJoinScope; -impl Drop for PromptJoinScope { - fn drop(&mut self) { - let handle = { - let Ok(mut printer) = super::PRINTER.lock() else { - return; - }; - let Some(handle) = printer.take_prompt_task_if_should_join() else { - return; - }; - handle - }; - let _: Result<_, _> = handle.join(); - } -} - -/// A string that will have its inner buffer zeroed when dropped -#[derive(Default, Clone)] -pub struct ZeroWhenDropString(String); -impl ZeroWhenDropString { - pub const fn new() -> Self { - Self(String::new()) - } -} -impl std::fmt::Display for ZeroWhenDropString { - #[inline(always)] - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - self.0.fmt(f) - } -} -impl From for ZeroWhenDropString { - #[inline(always)] - fn from(value: String) -> Self { - Self(value) - } -} -impl AsRef<[u8]> for ZeroWhenDropString { - #[inline(always)] - fn as_ref(&self) -> &[u8] { - self.0.as_ref() - } -} -impl AsRef for ZeroWhenDropString { - #[inline(always)] - fn as_ref(&self) -> &String { - &self.0 - } -} -impl AsRef for ZeroWhenDropString { - #[inline(always)] - fn as_ref(&self) -> &str { - &self.0 - } -} -impl Drop for ZeroWhenDropString { - #[inline(always)] - fn drop(&mut self) { - // SAFETY: we don't use the string again - for c in unsafe { self.0.as_bytes_mut() } { - // SAFETY: c is a valid u8 pointer - unsafe { std::ptr::write_volatile(c, 0) }; - } - std::sync::atomic::fence(std::sync::atomic::Ordering::SeqCst); - std::sync::atomic::compiler_fence(std::sync::atomic::Ordering::SeqCst); - } -} -impl std::ops::Deref for ZeroWhenDropString { - type Target = String; - #[inline(always)] - fn deref(&self) -> &String { - &self.0 - } -} -impl std::ops::DerefMut for ZeroWhenDropString { - #[inline(always)] - fn deref_mut(&mut self) -> &mut Self::Target { - &mut self.0 - } -} diff --git a/packages/copper/src/process/arg.rs b/packages/copper/src/process/arg.rs index db55126..3808e45 100644 --- a/packages/copper/src/process/arg.rs +++ b/packages/copper/src/process/arg.rs @@ -1,5 +1,8 @@ use tokio::process::Command as TokioCommand; +#[cfg(feature = "print")] +use crate::cli::fmt; + /// Add arguments to the command #[doc(hidden)] pub trait Config { @@ -94,7 +97,7 @@ impl ColorFlag { } impl std::fmt::Display for ColorFlag { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - let flag = if crate::color_enabled() { + let flag = if crate::lv::color_enabled() { "always" } else { "never" @@ -108,7 +111,7 @@ impl std::fmt::Display for ColorFlag { } impl Config for ColorFlag { fn configure(self, command: &mut TokioCommand) { - let flag = if crate::color_enabled() { + let flag = if crate::lv::color_enabled() { "always" } else { "never" @@ -179,7 +182,7 @@ impl WidthFlag { #[cfg(feature = "print")] impl Config for WidthFlag { fn configure(self, command: &mut TokioCommand) { - if let Some(w) = crate::term_width() { + if let Some(w) = fmt::term_width() { if self.use_eq_sign() { command.arg(format!("--width={w}")); } else { diff --git a/packages/copper/src/process/builder.rs b/packages/copper/src/process/builder.rs index b81c4ae..ff863c3 100644 --- a/packages/copper/src/process/builder.rs +++ b/packages/copper/src/process/builder.rs @@ -6,7 +6,7 @@ use tokio::process::{Child as TokioChild, Command as TokioCommand}; use super::{Child, ChildIo, Config, Preset}; -use crate::{Context as _, PathExtension as _, co, pio}; +use crate::{Context as _, co, pio, str::PathExtension as _}; /// A [`Command`] to be built pub type CommandBuilder = Command<(), (), ()>; @@ -541,7 +541,7 @@ fn pre_spawn crate::Result> { - let mut ms = 100; + let ms = 100; let mut total_ms = 0; loop { match self.inner.try_wait() { @@ -99,13 +99,12 @@ impl Child { if Duration::from_millis(total_ms) >= timeout { break; } - ms *= 4; } Ok(None) } pub fn wait_timeout(&mut self, timeout: Duration) -> crate::Result> { - let mut ms = 100; + let ms = 100; let mut total_ms = 0; loop { match self.inner.try_wait() { @@ -120,7 +119,6 @@ impl Child { if Duration::from_millis(total_ms) >= timeout { break; } - ms *= 4; } Ok(None) } @@ -130,7 +128,6 @@ impl Child { /// # Panic /// Will panic if called outside of a tokio runtime context pub async fn co_kill(mut self) -> crate::Result { - self.io.co_join(&self.name).await; let mut ms = 100; for i in 0..5 { crate::trace!("trying to kill child '{}', attempt {}", self.name, i + 1); @@ -148,6 +145,7 @@ impl Child { tokio::time::sleep(Duration::from_millis(ms)).await; ms *= 4; } + self.io.co_join(&self.name).await; crate::bail!("failed to kill child '{}' after many attempts", self.name); } @@ -157,7 +155,6 @@ impl Child { /// This will block the current thread while trying to join the child. /// Use [`co_kill`](Self::co_kill) to avoid blocking if in async context. pub fn kill(mut self) -> crate::Result { - self.io.join(&self.name); let mut ms = 100; for i in 0..5 { crate::trace!("trying to kill child '{}', attempt {}", self.name, i + 1); @@ -175,6 +172,7 @@ impl Child { std::thread::sleep(Duration::from_millis(ms)); ms *= 4; } + self.io.join(&self.name); crate::bail!("failed to kill child '{}' after many attempts", self.name); } } diff --git a/packages/copper/src/process/pio/cargo_preset.rs b/packages/copper/src/process/pio/cargo_preset.rs index f4ab326..dab7fd2 100644 --- a/packages/copper/src/process/pio/cargo_preset.rs +++ b/packages/copper/src/process/pio/cargo_preset.rs @@ -8,7 +8,7 @@ use tokio::process::{Child as TokioChild, ChildStderr, ChildStdout, Command as T use crate::lv::Lv; use crate::process::{Command, Preset, pio}; -use crate::{BoxedFuture, ProgressBar}; +use crate::{BoxedFuture, ProgressBar, ProgressBarBuilder}; /// Display progress of cargo task with a progress bar, and emitting /// status messages and diagnostic messages using this crate's printing utilities. @@ -22,7 +22,7 @@ use crate::{BoxedFuture, ProgressBar}; /// # fn main() -> cu::Result<()> { /// cu::which("cargo")?.command() /// .args(["build", "--release"]) -/// .preset(cu::pio::cargo()) +/// .preset(cu::pio::cargo("building my crate")) /// .spawn()?.0 /// .wait_nz()?; /// # Ok(()) } @@ -38,6 +38,8 @@ use crate::{BoxedFuture, ProgressBar}; /// crates being built in one line (similar to the build progress bar shown /// by cargo). /// +/// You can customize the spawned progress bar with +/// /// # Message levels /// Errors, warnings and status messages (like `Compiling foobar v0.1.0`) /// can be configured with the [`error`](Cargo::error), [`warning`](Cargo::warning), @@ -47,7 +49,7 @@ use crate::{BoxedFuture, ProgressBar}; /// # use pistonite_cu as cu; /// use cu::pre::*; /// -/// cu::pio::cargo() +/// cu::pio::cargo("cargo build") /// // configure message levels; levels shown here are the default /// .error(cu::lv::E) /// .warning(cu::lv::W) @@ -64,7 +66,7 @@ use crate::{BoxedFuture, ProgressBar}; /// # use pistonite_cu as cu; /// use cu::pre::*; /// -/// cu::pio::cargo() +/// cu::pio::cargo("cargo build") /// // configure message levels; levels shown here are the default /// .on_diagnostic(|is_warning, message| { /// // this implementation will be identical to the default behavior @@ -77,15 +79,18 @@ use crate::{BoxedFuture, ProgressBar}; /// ``` /// /// # Output -/// The handle to the progress bar is emitted to both the stdout and stderr slot. -/// Be sure to manually drop the handle to mark the progress as done if needed. +/// The handle to the progress bar is emitted to the stdout slot. +/// Be sure to manually call `.done()` on it. See [Progress Bars](fn@crate::progress) +/// for more details /// -pub fn cargo() -> Cargo { +#[inline(always)] +pub fn cargo(progress_message: impl Into) -> Cargo { Cargo { error_lv: Lv::Error, warning_lv: Lv::Warn, other_lv: Lv::Debug, diagnostic_hook: None, + progress_builder: crate::progress(progress_message), } } pub struct Cargo { @@ -93,6 +98,7 @@ pub struct Cargo { warning_lv: Lv, other_lv: Lv, diagnostic_hook: Option, + progress_builder: ProgressBarBuilder, } impl Cargo { @@ -120,24 +126,30 @@ impl Cargo { self.diagnostic_hook = Some(Box::new(f)); self } + + /// Configure the progress bar that will be spawned + #[inline(always)] + pub fn configure_spinner ProgressBarBuilder>( + mut self, + f: F, + ) -> Self { + self.progress_builder = f(self.progress_builder); + self + } } impl Preset for Cargo { - type Output = Command; + type Output = Command; fn configure(self, command: crate::Command) -> Self::Output { command .args(["--message-format=json-diagnostic-rendered-ansi"]) - .stderr(Cargo { - error_lv: self.error_lv, - warning_lv: self.warning_lv, - other_lv: self.other_lv, - diagnostic_hook: None, - }) + .stderr(CargoStubStdErr) .stdout(self) .stdin_null() } } + pub struct CargoTask { error_lv: Lv, warning_lv: Lv, @@ -149,28 +161,17 @@ pub struct CargoTask { } impl pio::ChildOutConfig for Cargo { - type Task = Option; + type Task = CargoTask; type __Null = super::__OCNonNull; fn configure_stdout(&mut self, command: &mut TokioCommand) { command.stdout(Stdio::piped()); } - fn configure_stderr(&mut self, command: &mut TokioCommand) { - command.stderr(Stdio::piped()); - } - fn take( - self, - child: &mut TokioChild, - name: Option<&str>, - is_out: bool, - ) -> crate::Result { - // we need to take both out and err - if !is_out { - return Ok(None); - } + fn configure_stderr(&mut self, _: &mut TokioCommand) {} + fn take(self, child: &mut TokioChild, _: Option<&str>, _: bool) -> crate::Result { let stdout = super::take_child_stdout(child)?; let stderr = super::take_child_stderr(child)?; - let bar = crate::progress_unbounded(name.unwrap_or("cargo")); - Ok(Some(CargoTask { + let bar = self.progress_builder.spawn(); + Ok(CargoTask { error_lv: self.error_lv, warning_lv: self.warning_lv, other_lv: self.other_lv, @@ -178,20 +179,28 @@ impl pio::ChildOutConfig for Cargo { out: stdout, err: stderr, diagnostic_hook: self.diagnostic_hook, - })) + }) } } -impl pio::ChildOutTask for Option { - type Output = Option>; +pub struct CargoStubStdErr; +impl pio::ChildOutConfig for CargoStubStdErr { + type Task = (); + type __Null = super::__OCNull; + fn configure_stdout(&mut self, _: &mut TokioCommand) {} + fn configure_stderr(&mut self, command: &mut TokioCommand) { + command.stderr(Stdio::piped()); + } + fn take(self, _: &mut TokioChild, _: Option<&str>, _: bool) -> crate::Result { + Ok(()) + } +} + +impl pio::ChildOutTask for CargoTask { + type Output = Arc; fn run(self) -> (Option>, Self::Output) { - match self { - None => (None, None), - Some(task) => { - let bar = Arc::clone(&task.bar); - (Some(Box::pin(task.main())), Some(bar)) - } - } + let bar = Arc::clone(&self.bar); + (Some(Box::pin(self.main())), bar) } } @@ -202,13 +211,15 @@ impl CargoTask { let read_err = tokio::io::BufReader::new(self.err); let mut err_lines = Some(read_err.lines()); - crate::progress!(&self.bar, (), "preparing"); + let bar = self.bar; + + crate::progress!(bar, "preparing"); let mut state = PrintState::new( self.error_lv, self.warning_lv, self.other_lv, - self.bar, + bar, self.diagnostic_hook, ); @@ -317,7 +328,7 @@ impl PrintState { match message.level { Some("warning") => match &self.diagnostic_hook { None => { - crate::__priv::__print_with_level( + crate::cli::__print_with_level( self.warning_lv, format_args!("{rendered}"), ); @@ -326,7 +337,7 @@ impl PrintState { }, Some("error") => match &self.diagnostic_hook { None => { - crate::__priv::__print_with_level( + crate::cli::__print_with_level( self.error_lv, format_args!("{rendered}"), ); @@ -334,15 +345,13 @@ impl PrintState { Some(hook) => hook(false, &rendered), }, _ => { - crate::__priv::__print_with_level( - self.other_lv, - format_args!("{rendered}"), - ); + crate::cli::__print_with_level(self.other_lv, format_args!("{rendered}")); } } } "build-finished" => match payload.success { Some(true) => { + self.bar.done_by_ref(); crate::trace!("cargo build successful"); } _ => { @@ -370,26 +379,26 @@ impl PrintState { if let Some(lv) = self.stderr_printing_message_lv { // since the message might be multi-line, we // keep printing until a status message is matched - crate::__priv::__print_with_level(lv, format_args!("{line}")); + crate::cli::__print_with_level(lv, format_args!("{line}")); return; } // check if the message matches error/warning if ERROR_REGEX.is_match(line) { - crate::__priv::__print_with_level(self.error_lv, format_args!("{line}")); + crate::cli::__print_with_level(self.error_lv, format_args!("{line}")); self.stderr_printing_message_lv = Some(self.error_lv); return; } if WARNING_REGEX.is_match(line) { - crate::__priv::__print_with_level(self.warning_lv, format_args!("{line}")); + crate::cli::__print_with_level(self.warning_lv, format_args!("{line}")); self.stderr_printing_message_lv = Some(self.warning_lv); return; } // print as other message - crate::__priv::__print_with_level(self.other_lv, format_args!("{line}")); + crate::cli::__print_with_level(self.other_lv, format_args!("{line}")); return; }; // print the status message as other, and clear the error/warning message state - crate::__priv::__print_with_level(self.other_lv, format_args!("{line}")); + crate::cli::__print_with_level(self.other_lv, format_args!("{line}")); self.stderr_printing_message_lv = None; // process the status message @@ -405,6 +414,8 @@ impl PrintState { fn update_bar(&mut self) { let count = self.done_count; + let bar = &self.bar; + self.buf.clear(); let mut iter = self.in_progress.iter(); if let Some(x) = iter.next() { @@ -413,9 +424,9 @@ impl PrintState { self.buf.push_str(", "); self.buf.push_str(c); } - crate::progress!(&self.bar, (), "{count} done, compiling: {}", self.buf); + crate::progress!(bar, "{count} done, compiling: {}", self.buf); } else if count != 0 { - crate::progress!(&self.bar, (), "{count} done"); + crate::progress!(bar, "{count} done"); } } } diff --git a/packages/copper/src/process/pio/print.rs b/packages/copper/src/process/pio/print.rs index 7b14755..7b39eba 100644 --- a/packages/copper/src/process/pio/print.rs +++ b/packages/copper/src/process/pio/print.rs @@ -72,7 +72,7 @@ impl PrintTask { match driver.next().await { DriverOutput::Line(line) => { for l in line.lines() { - crate::__priv::__print_with_level(lv, format_args!("{prefix}{l}")); + crate::cli::__print_with_level(lv, format_args!("{prefix}{l}")); } } DriverOutput::Done => break, diff --git a/packages/copper/src/process/pio/print_driver.rs b/packages/copper/src/process/pio/print_driver.rs index 9f8c416..d14ef76 100644 --- a/packages/copper/src/process/pio/print_driver.rs +++ b/packages/copper/src/process/pio/print_driver.rs @@ -1,6 +1,8 @@ use tokio::io::AsyncReadExt as _; use tokio::process::{ChildStderr, ChildStdout}; +use crate::cli::fmt::{ansi, utf8}; + /// Drive that takes the out stream and err stream, and produces lines pub(crate) struct Driver { out: Option, @@ -159,7 +161,6 @@ impl Driver { line: &mut String, only_last_line: bool, ) -> (usize, bool) { - use crate::print::{ansi, utf8}; let mut i = 0; let mut invalid_while_escaping = false; let mut start_escape_pos: Option = None; @@ -183,7 +184,7 @@ impl Driver { let prev = last; last = c; if let Some(s) = start_escape_pos { - if ansi::is_ansi_end_char(c) { + if ansi::is_esc_end(c) { start_escape_pos = None; // allow color codes if !invalid_while_escaping && c == 'm' { diff --git a/packages/copper/src/process/pio/spinner.rs b/packages/copper/src/process/pio/spinner.rs index d6404a8..057711d 100644 --- a/packages/copper/src/process/pio/spinner.rs +++ b/packages/copper/src/process/pio/spinner.rs @@ -4,7 +4,8 @@ use std::sync::Arc; use spin::mutex::SpinMutex; use tokio::process::{Child as TokioChild, ChildStderr, ChildStdout, Command as TokioCommand}; -use crate::{Atomic, BoxedFuture, ProgressBar, lv::Lv}; +use crate::lv::Lv; +use crate::{Atomic, BoxedFuture, ProgressBar, ProgressBarBuilder}; use super::{ChildOutConfig, ChildOutTask, Driver, DriverOutput}; @@ -63,10 +64,9 @@ use super::{ChildOutConfig, ChildOutTask, Driver, DriverOutput}; #[inline(always)] pub fn spinner(name: impl Into) -> Spinner { Spinner { - prefix: name.into(), config: Arc::new(SpinnerInner { lv: Atomic::new_u8(Lv::Off as u8), - bar: SpinMutex::new(None), + bar: SpinMutex::new(Err(crate::progress(name))), }), } } @@ -74,9 +74,6 @@ pub fn spinner(name: impl Into) -> Spinner { #[derive(Clone)] #[doc(hidden)] pub struct Spinner { - /// prefix of the bar - prefix: String, - config: Arc, } #[rustfmt::skip] @@ -101,7 +98,7 @@ struct SpinnerInner { // the bar spawned when calling take() for the first time, // using a spin lock because it should be VERY rare that // we get contention - bar: SpinMutex>>, + bar: SpinMutex, ProgressBarBuilder>>, } pub struct SpinnerTask { lv: Lv, @@ -126,7 +123,7 @@ impl ChildOutConfig for Spinner { is_out: bool, ) -> crate::Result { let lv = self.config.lv.get(); - let log_prefix = if crate::log_enabled(lv) { + let log_prefix = if lv.enabled() { let name = name.unwrap_or_default(); if name.is_empty() { String::new() @@ -138,12 +135,15 @@ impl ChildOutConfig for Spinner { }; let bar = { let mut bar_arc = self.config.bar.lock(); - if let Some(bar) = bar_arc.as_ref() { - Arc::clone(bar) - } else { - let bar = crate::progress_unbounded(self.prefix); - *bar_arc = Some(Arc::clone(&bar)); - bar + match bar_arc.as_mut() { + // if already created, then just use the bar (i.e. if created + // by the same spinner configured for multiple outputs + Ok(bar) => Arc::clone(bar), + Err(e) => { + let bar = e.clone().spawn(); + *bar_arc = Ok(Arc::clone(&bar)); + bar + } } }; Ok(SpinnerTask { @@ -175,15 +175,15 @@ impl SpinnerTask { match driver.next().await { DriverOutput::Line(line) => { if lv != Lv::Off { - crate::__priv::__print_with_level(lv, format_args!("{prefix}{line}")); + crate::cli::__print_with_level(lv, format_args!("{prefix}{line}")); // erase the progress line if we decide to print it out - crate::progress!(&bar, (), "") + crate::progress!(bar, "") } else { - crate::progress!(&bar, (), "{line}") + crate::progress!(bar, "{line}") } } DriverOutput::Progress(line) => { - crate::progress!(&bar, (), "{line}") + crate::progress!(bar, "{line}") } DriverOutput::Done => break, _ => {} diff --git a/packages/copper/src/str/byte_format.rs b/packages/copper/src/str/byte_format.rs new file mode 100644 index 0000000..afab19a --- /dev/null +++ b/packages/copper/src/str/byte_format.rs @@ -0,0 +1,23 @@ +/// Format integer in SI bytes. +/// +/// The accuracy is 1 decimal, i.e `999.9T`. +/// +/// Available units are `T`, `G`, `M`, `k`, `B` +pub struct ByteFormat(pub u64); +impl std::fmt::Display for ByteFormat { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + for (unit_bytes, unit_char) in [ + (1_000_000_000_000, 'T'), + (1_000_000_000, 'G'), + (1_000_000, 'M'), + (1_000, 'k'), + ] { + if self.0 >= unit_bytes { + let whole = self.0 / unit_bytes; + let deci = (self.0 % unit_bytes) * 10 / unit_bytes; + return write!(f, "{whole}.{deci}{unit_char}"); + } + } + write!(f, "{}B", self.0) + } +} diff --git a/packages/copper/src/str/mod.rs b/packages/copper/src/str/mod.rs new file mode 100644 index 0000000..692a261 --- /dev/null +++ b/packages/copper/src/str/mod.rs @@ -0,0 +1,16 @@ +//! Working with bytes + +mod zstring; +pub use zstring::{ZString, zero}; +mod byte_format; +pub use byte_format::ByteFormat; + +mod osstring; +pub use osstring::{OsStrExtension, OsStrExtensionOwned}; + +// path requires fs since there are utils that checks for existence +// (check_exists, normalize) +#[cfg(feature = "fs")] +mod path; +#[cfg(feature = "fs")] +pub use path::PathExtension; diff --git a/packages/copper/src/str/osstring.rs b/packages/copper/src/str/osstring.rs new file mode 100644 index 0000000..1088717 --- /dev/null +++ b/packages/copper/src/str/osstring.rs @@ -0,0 +1,115 @@ +use std::ffi::{OsStr, OsString}; +use std::path::{Path, PathBuf}; + +use cu::Context as _; + +/// Convenience trait for working with [`OsStr`](std::ffi::OsStr) +/// +/// See [File System Paths and Strings](trait@crate::str::PathExtension) +pub trait OsStrExtension { + /// Get the path as UTF-8, error if it's not UTF-8 + fn as_utf8(&self) -> cu::Result<&str>; +} +/// Convenience trait for working with [`OsString`](std::ffi::OsString) +/// +/// See [File System Paths and Strings](trait@crate::str::PathExtension) +pub trait OsStrExtensionOwned { + /// Get the path as UTF-8, error if it's not UTF-8 + fn into_utf8(self) -> cu::Result; +} + +impl OsStrExtension for OsStr { + #[inline(always)] + fn as_utf8(&self) -> cu::Result<&str> { + // to_str is ok on all platforms, because Rust internally + // represent OsStrings on Windows as WTF-8 + // see https://doc.rust-lang.org/src/std/sys_common/wtf8.rs.html + cu::check!(self.to_str(), "not utf-8: {self:?}") + } +} + +impl OsStrExtension for Path { + #[inline(always)] + fn as_utf8(&self) -> cu::Result<&str> { + self.as_os_str().as_utf8() + } +} + +impl OsStrExtensionOwned for OsString { + #[inline(always)] + fn into_utf8(self) -> cu::Result { + match self.into_string() { + Ok(s) => Ok(s), + Err(e) => cu::bail!("not utf-8: {e:?}"), + } + } +} +impl OsStrExtensionOwned for PathBuf { + #[inline(always)] + fn into_utf8(self) -> cu::Result { + self.into_os_string().into_utf8() + } +} + +#[cfg(all(test, unix))] +mod test { + use super::*; + use std::os::unix::ffi::OsStrExt as _; + #[test] + fn test_not_utf8() { + let s = OsStr::from_bytes(b"hello\xffworld"); + let result = s.as_utf8().unwrap_err().to_string(); + assert_eq!(result, r#"not utf-8: "hello\xFFworld""#); + let result = Path::new(s).as_utf8().unwrap_err().to_string(); + assert_eq!(result, r#"not utf-8: "hello\xFFworld""#); + + let s = s.to_owned(); + let result = s.clone().into_utf8().unwrap_err().to_string(); + assert_eq!(result, r#"not utf-8: "hello\xFFworld""#); + let result = PathBuf::from(s).into_utf8().unwrap_err().to_string(); + assert_eq!(result, r#"not utf-8: "hello\xFFworld""#); + } + #[test] + fn test_utf8() { + let s = OsStr::from_bytes(b"hello world"); + assert!(s.as_utf8().is_ok()); + assert!(Path::new(s).as_utf8().is_ok()); + + let s = s.to_owned(); + assert!(s.clone().into_utf8().is_ok()); + assert!(PathBuf::from(s).into_utf8().is_ok()); + } +} + +#[cfg(all(test, windows))] +mod test { + use super::*; + use std::os::windows::ffi::OsStringExt as _; + #[test] + fn test_not_utf8() { + let wide: &[u16] = &[ + 0x0068, 0x0065, 0x006C, 0x006C, 0x006F, 0xD800, 0x0077, 0x006F, 0x0072, 0x006C, 0x0064, + ]; + let s = OsString::from_wide(wide); + let result = s.as_utf8().unwrap_err().to_string(); + assert_eq!(result, r#"not utf-8: "hello\u{d800}world""#); + let result = Path::new(&s).as_utf8().unwrap_err().to_string(); + assert_eq!(result, r#"not utf-8: "hello\u{d800}world""#); + + let result = s.clone().into_utf8().unwrap_err().to_string(); + assert_eq!(result, r#"not utf-8: "hello\u{d800}world""#); + let result = PathBuf::from(s).into_utf8().unwrap_err().to_string(); + assert_eq!(result, r#"not utf-8: "hello\u{d800}world""#); + } + #[test] + fn test_utf8() { + let wide: &[u16] = &[ + 0x0068, 0x0065, 0x006C, 0x006C, 0x006F, 0x0020, 0x0077, 0x006F, 0x0072, 0x006C, 0x0064, + ]; + let s = OsString::from_wide(wide); + assert!(s.as_utf8().is_ok()); + assert!(Path::new(&s).as_utf8().is_ok()); + assert!(s.clone().into_utf8().is_ok()); + assert!(PathBuf::from(s).into_utf8().is_ok()); + } +} diff --git a/packages/copper/src/path.rs b/packages/copper/src/str/path.rs similarity index 58% rename from packages/copper/src/path.rs rename to packages/copper/src/str/path.rs index e1bb018..806429e 100644 --- a/packages/copper/src/path.rs +++ b/packages/copper/src/str/path.rs @@ -1,23 +1,57 @@ use std::borrow::Cow; use std::path::{Path, PathBuf}; -use crate::Context as _; +use cu::Context as _; -/// Extension to paths +/// # File System Paths and Strings +/// Rust works with [`String`](std::string)s, which are UTF-8 encoded bytes. +/// However, not all operating systems work with UTF-8. That's why Rust has +/// [`OsString`](std::ffi::OsString), which has platform-specific implementations. +/// And `PathBuf`s are wrappers for `OsString`. /// -/// Most of these are related to file system, and not purely path processing. -/// Therefore this is tied to the `fs` feature. +/// However, often when writing platform-independent code, we want to stay +/// in the UTF-8 realm, but conversion can be painful because you must handle +/// the error when the `OsString` is not valid UTF-8. +/// +/// ```rust +/// # use pistonite_cu as cu; +/// use std::ffi::{OsString, OsStr}; +/// +/// use cu::pre::*; +/// +/// fn take_os_string(s: &OsStr) -> cu::Result<()> { +/// match s.to_str() { +/// Some(s) => { +/// cu::info!("valid utf-8: {s}"); +/// Ok(()) +/// } +/// None => { +/// cu::bail!("not valid utf-8!"); +/// } +/// } +/// } +/// ``` +/// +/// `cu` provides extension traits that integrates with `cu::Result`, +/// so you can have the error handling by simply propagate with `?`. +/// +/// There are 4 traits, all will be included into scope with `use cu::pre::*;`. +/// The path extensions also have utilities for working with file system specifically, +/// (such as normalizing it), which is why they require the `fs` feature. +/// +/// - [`OsStrExtension`](trait@crate::str::OsStrExtension) +/// - [`OsStrExtensionOwned`](trait@crate::str::OsStrExtensionOwned) +/// - `PathExtension` - requires `fs` feature pub trait PathExtension { /// Get file name. Error if the file name is not UTF-8 or other error occurs - fn file_name_str(&self) -> crate::Result<&str>; - - /// Get the path as UTF-8, error if it's not UTF-8 - fn as_utf8(&self) -> crate::Result<&str>; + fn file_name_str(&self) -> cu::Result<&str>; /// Check that the path exists, or fail with an error - fn check_exists(&self) -> crate::Result<()>; + fn ensure_exists(&self) -> cu::Result<()>; /// Return the simplified path if the path has a Windows UNC prefix + /// + /// The behavior is the same cross-platform. fn simplified(&self) -> &Path; /// Get absolute path for a path. @@ -32,33 +66,36 @@ pub trait PathExtension { /// /// On Windows only, it returns the most compatible form using the `dunce` crate instead of /// UNC, and the drive letter is also normalized to upper case - fn normalize(&self) -> crate::Result; + fn normalize(&self) -> cu::Result; /// Like `normalize`, but with the additional guarantee that the path exists - fn normalize_exists(&self) -> crate::Result { + fn normalize_exists(&self) -> cu::Result { let x = self.normalize()?; - x.check_exists()?; + x.ensure_exists()?; Ok(x) } /// Like `normalize`, but with the additional guarantee that: - /// - The file name of the output will be the same as the input + /// - The file name of the output will be the same as the input. This is because + /// the executable can be a multicall binary that behaves differently + /// depending on the executable name. /// - The path exists and is not a directory - fn normalize_executable(&self) -> crate::Result; + fn normalize_executable(&self) -> cu::Result; /// Get the parent path as an absolute path /// /// Path navigation is very complex and that's why we are paying a little performance /// cost and always returning `PathBuf`, and always converting the path to absolute. /// - /// For path manipulating (i.e. as a OsStr), instead of navigation, use std `parent()` + /// For path manipulation (i.e. as a OsStr), instead of navigation, use std `parent()` /// instead - fn parent_abs(&self) -> crate::Result { + #[inline(always)] + fn parent_abs(&self) -> cu::Result { self.parent_abs_times(1) } /// Effecitvely chaining `parent_abs` `x` times - fn parent_abs_times(&self, x: usize) -> crate::Result; + fn parent_abs_times(&self, x: usize) -> cu::Result; /// Try converting self to a relative path from current working directory. /// @@ -71,43 +108,26 @@ pub trait PathExtension { fn try_to_rel_from(&self, path: impl AsRef) -> Cow<'_, Path>; /// Start building a child process with the path as the executable + /// + /// See [Spawn Commands](crate::CommandBuilder) #[cfg(feature = "process")] - fn command(&self) -> crate::CommandBuilder; -} - -/// Extension to paths -/// -/// Most of these are related to file system, and not purely path processing. -/// Therefore this is tied to the `fs` feature. -pub trait PathExtensionOwned { - /// Get the path as UTF-8, error if it's not UTF-8 - fn into_utf8(self) -> crate::Result; + fn command(&self) -> cu::CommandBuilder; } impl PathExtension for Path { - fn file_name_str(&self) -> crate::Result<&str> { + fn file_name_str(&self) -> cu::Result<&str> { let file_name = self .file_name() - .with_context(|| format!("cannot get file name for path: {}", self.display()))?; + .with_context(|| format!("cannot get file name for path: '{}'", self.display()))?; // to_str is ok on all platforms, because Rust internally // represent OsStrings on Windows as WTF-8 // see https://doc.rust-lang.org/src/std/sys_common/wtf8.rs.html let Some(file_name) = file_name.to_str() else { - crate::bail!("file name is not valid UTF-8: {}", self.display()); + crate::bail!("file name is not utf-8: '{}'", self.display()); }; Ok(file_name) } - fn as_utf8(&self) -> crate::Result<&str> { - // to_str is ok on all platforms, because Rust internally - // represent OsStrings on Windows as WTF-8 - // see https://doc.rust-lang.org/src/std/sys_common/wtf8.rs.html - let Some(path) = self.to_str() else { - crate::bail!("path is not valid UTF-8: {}", self.display()); - }; - Ok(path) - } - fn simplified(&self) -> &Path { if self.as_os_str().as_encoded_bytes().starts_with(b"\\\\") { dunce::simplified(self) @@ -116,14 +136,14 @@ impl PathExtension for Path { } } - fn check_exists(&self) -> crate::Result<()> { + fn ensure_exists(&self) -> cu::Result<()> { if !self.exists() { crate::bail!("path '{}' does not exist.", self.display()); } Ok(()) } - fn normalize(&self) -> crate::Result { + fn normalize(&self) -> cu::Result { if let Ok(x) = dunce::canonicalize(self) { return Ok(x); }; @@ -132,9 +152,8 @@ impl PathExtension for Path { } let Ok(mut base) = dunce::canonicalize(".") else { - crate::warn!("failed to normalize current directory"); crate::bail!( - "cannot normalize current directory when normalizing relative path: {}", + "failed to normalize current directory when normalizing relative path: '{}'", self.display() ); }; @@ -150,9 +169,8 @@ impl PathExtension for Path { fallback_normalize_absolute(self)? } else { let Ok(mut base) = dunce::canonicalize(".") else { - crate::warn!("failed to normalize current directory"); crate::bail!( - "cannot normalize current directory when normalizing relative path: {}", + "failed to normalize current directory when normalizing relative path: '{}'", self.display() ); }; @@ -187,36 +205,9 @@ impl PathExtension for Path { Ok(out) } + #[inline(always)] fn try_to_rel_from(&self, path: impl AsRef) -> Cow<'_, Path> { - let path = path.as_ref(); - let res = match (self.is_absolute(), path.is_absolute()) { - (true, true) => pathdiff::diff_paths(self, path), - (true, false) => { - let Ok(base) = path.normalize() else { - return Cow::Borrowed(self); - }; - pathdiff::diff_paths(self, base.as_path()) - } - (false, true) => { - let Ok(self_) = self.normalize() else { - return Cow::Borrowed(self); - }; - pathdiff::diff_paths(self_.as_path(), path) - } - (false, false) => { - let Ok(self_) = self.normalize() else { - return Cow::Borrowed(self); - }; - let Ok(base) = path.normalize() else { - return Cow::Borrowed(self); - }; - pathdiff::diff_paths(self_.as_path(), base.as_path()) - } - }; - match res { - None => Cow::Borrowed(self), - Some(x) => Cow::Owned(x), - } + try_to_rel_from(self, path.as_ref()) } #[cfg(feature = "process")] @@ -266,47 +257,61 @@ fn fallback_normalize_absolute(path: &Path) -> crate::Result { } } -macro_rules! impl_for_as_ref_path { - ($type:ty) => { - impl PathExtension for $type { - fn file_name_str(&self) -> crate::Result<&str> { - AsRef::::as_ref(self).file_name_str() - } - fn as_utf8(&self) -> crate::Result<&str> { - AsRef::::as_ref(self).as_utf8() - } - fn simplified(&self) -> &Path { - AsRef::::as_ref(self).simplified() - } - fn normalize(&self) -> crate::Result { - AsRef::::as_ref(self).normalize() - } - fn normalize_executable(&self) -> crate::Result { - AsRef::::as_ref(self).normalize_executable() - } - fn check_exists(&self) -> crate::Result<()> { - AsRef::::as_ref(self).check_exists() - } - fn parent_abs_times(&self, x: usize) -> crate::Result { - AsRef::::as_ref(self).parent_abs_times(x) - } - fn try_to_rel_from(&self, path: impl AsRef) -> Cow<'_, Path> { - AsRef::::as_ref(self).try_to_rel_from(path) - } - #[cfg(feature = "process")] - fn command(&self) -> crate::CommandBuilder { - AsRef::::as_ref(self).command() - } +fn try_to_rel_from<'a>(self_: &'a Path, path: &Path) -> Cow<'a, Path> { + let res = match (self_.is_absolute(), path.is_absolute()) { + (true, true) => pathdiff::diff_paths(self_, path), + (true, false) => { + let Ok(base) = path.normalize() else { + return Cow::Borrowed(self_); + }; + pathdiff::diff_paths(self_, base.as_path()) + } + (false, true) => { + let Ok(self_) = self_.normalize() else { + return Cow::Borrowed(self_); + }; + pathdiff::diff_paths(self_.as_path(), path) + } + (false, false) => { + let Ok(self_abs) = self_.normalize() else { + return Cow::Borrowed(self_); + }; + let Ok(base) = path.normalize() else { + return Cow::Borrowed(self_); + }; + pathdiff::diff_paths(self_abs.as_path(), base.as_path()) } }; + match res { + None => Cow::Borrowed(self_), + Some(x) => Cow::Owned(x), + } } -impl_for_as_ref_path!(PathBuf); - -impl PathExtensionOwned for PathBuf { - fn into_utf8(self) -> crate::Result { - self.into_os_string() - .into_string() - .map_err(|e| crate::fmterr!("path is not valid UTF-8: {}", e.display())) +impl PathExtension for PathBuf { + fn file_name_str(&self) -> crate::Result<&str> { + self.as_path().file_name_str() + } + fn simplified(&self) -> &Path { + self.as_path().simplified() + } + fn normalize(&self) -> crate::Result { + self.as_path().normalize() + } + fn normalize_executable(&self) -> crate::Result { + self.as_path().normalize_executable() + } + fn ensure_exists(&self) -> crate::Result<()> { + self.as_path().ensure_exists() + } + fn parent_abs_times(&self, x: usize) -> crate::Result { + self.as_path().parent_abs_times(x) + } + fn try_to_rel_from(&self, path: impl AsRef) -> Cow<'_, Path> { + self.as_path().try_to_rel_from(path) + } + #[cfg(feature = "process")] + fn command(&self) -> crate::CommandBuilder { + self.as_path().command() } } diff --git a/packages/copper/src/str/zstring.rs b/packages/copper/src/str/zstring.rs new file mode 100644 index 0000000..d0e0dac --- /dev/null +++ b/packages/copper/src/str/zstring.rs @@ -0,0 +1,79 @@ +/// A string that will have its inner buffer zeroed when dropped +#[derive(Default, Clone)] +pub struct ZString(String); +impl ZString { + pub const fn new() -> Self { + Self(String::new()) + } +} +impl std::fmt::Display for ZString { + #[inline(always)] + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + self.0.fmt(f) + } +} +impl From for ZString { + #[inline(always)] + fn from(value: String) -> Self { + Self(value) + } +} +impl AsRef<[u8]> for ZString { + #[inline(always)] + fn as_ref(&self) -> &[u8] { + self.0.as_ref() + } +} +impl AsRef for ZString { + #[inline(always)] + fn as_ref(&self) -> &String { + &self.0 + } +} +impl AsRef for ZString { + #[inline(always)] + fn as_ref(&self) -> &str { + &self.0 + } +} +impl Drop for ZString { + #[inline(always)] + fn drop(&mut self) { + // safety: we are dropped + unsafe { do_zero(&mut self.0) } + } +} +impl std::ops::Deref for ZString { + type Target = String; + #[inline(always)] + fn deref(&self) -> &String { + &self.0 + } +} +impl std::ops::DerefMut for ZString { + #[inline(always)] + fn deref_mut(&mut self) -> &mut Self::Target { + &mut self.0 + } +} + +/// Write 0's to the internal buffer of the string +#[inline(always)] +pub fn zero(s: &mut String) { + let mut s = std::mem::take(s); + // safety: s is dropped afterwards when going out of scope + unsafe { do_zero(&mut s) } +} + +// Safety: the string must be dropped afterwards +#[allow(clippy::ptr_arg)] +unsafe fn do_zero(s: &mut String) { + // SAFETY: we don't use the string again + for c in unsafe { s.as_bytes_mut() } { + // SAFETY: c is a valid u8 pointer + unsafe { std::ptr::write_volatile(c, 0) }; + } + // ensure other threads see this change + std::sync::atomic::fence(std::sync::atomic::Ordering::SeqCst); + std::sync::atomic::compiler_fence(std::sync::atomic::Ordering::SeqCst); +} diff --git a/packages/copper/tests/error_ctx.rs b/packages/copper/tests/context.rs similarity index 83% rename from packages/copper/tests/error_ctx.rs rename to packages/copper/tests/context.rs index f715eb9..04be7d3 100644 --- a/packages/copper/tests/error_ctx.rs +++ b/packages/copper/tests/context.rs @@ -12,7 +12,7 @@ Caused by: ) } -#[cu::error_ctx("failed with arg {arg}")] +#[cu::context("failed with arg {arg}")] fn example1(arg: u32) -> cu::Result<()> { cu::bail!("example1") } @@ -31,7 +31,7 @@ Caused by: // 'pre' is needed because s is moved into the function // so the error message needs to be formatted before running the function -#[cu::error_ctx(pre, format("failed with arg {s}"))] +#[cu::context(pre, format("failed with arg {s}"))] fn example2(s: String) -> cu::Result<()> { cu::bail!("example2: {s}") } @@ -44,7 +44,7 @@ async fn test_example3_err() { r"async failed with arg 4 Caused by: - Condition failed: `value > 4` (4 vs 4)" + condition failed: `value > 4`" ) } @@ -54,10 +54,10 @@ async fn test_example3_ok() { } // question mark works as expected (context is added at return time) -#[cu::error_ctx("async failed with arg {}", s)] +#[cu::context("async failed with arg {}", s)] async fn example3(s: u32) -> cu::Result<()> { let value = returns_ok(s)?; - cu::ensure!(value > 4); + cu::ensure!(value > 4)?; Ok(()) } @@ -69,7 +69,7 @@ async fn test_example4_err() { r"async failed with arg Caused by: - Condition failed: `!value.is_empty()`" + condition failed: `!value.is_empty()`" ) } @@ -79,10 +79,10 @@ async fn test_example4_ok() { } // question mark works as expected (context is added at return time) -#[cu::error_ctx(pre, format("async failed with arg {s}"))] +#[cu::context(pre, format("async failed with arg {s}"))] async fn example4(s: String) -> cu::Result<()> { let value = returns_ok(s)?; - cu::ensure!(!value.is_empty()); + cu::ensure!(!value.is_empty())?; Ok(()) } @@ -101,7 +101,7 @@ Caused by: // associated functions also work struct Foo(u32); impl Foo { - #[cu::error_ctx("Foo failed with arg {}", self.0)] + #[cu::context("Foo failed with arg {}", self.0)] fn example5(&self) -> cu::Result<()> { cu::bail!("example5") } diff --git a/packages/promethium/Cargo.toml b/packages/promethium/Cargo.toml index 1eac471..5d5e0c6 100644 --- a/packages/promethium/Cargo.toml +++ b/packages/promethium/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "pistonite-pm" -version = "0.2.3" +version = "0.2.4" edition = "2024" description = "Procedural Macro Common Utils" repository = "https://github.com/Pistonite/cu" @@ -11,9 +11,9 @@ exclude = [ ] [dependencies] -proc-macro2 = { version = "1.0.104", default-features = false } -quote = { version = "1.0.42", default-features = false } -syn = { version = "2.0.113", default-features = false } +proc-macro2 = { version = "1.0.105", default-features = false } +quote = { version = "1.0.43", default-features = false } +syn = { version = "2.0.114", default-features = false } [features] default = [ diff --git a/packages/promethium/Taskfile.yml b/packages/promethium/Taskfile.yml index ed9be82..f5e50b4 100644 --- a/packages/promethium/Taskfile.yml +++ b/packages/promethium/Taskfile.yml @@ -8,18 +8,14 @@ includes: tasks: check: - cmds: - - task: cargo:clippy-all - - task: cargo:fmt-check + - task: cargo:clippy-all + - task: cargo:fmt-check fix: - cmds: - - task: cargo:fmt-fix + - task: cargo:fmt-fix publish: - cmds: - - cmd: cargo publish - ignore_error: true + - cmd: cargo publish + ignore_error: true test: - cmds: - - cargo test - - cargo test --features full - - cargo test --no-default-features --features full + - cargo test + - cargo test --features full + - cargo test --no-default-features --features full diff --git a/packages/terminal-tests/.gitignore b/packages/terminal-tests/.gitignore new file mode 100644 index 0000000..e712304 --- /dev/null +++ b/packages/terminal-tests/.gitignore @@ -0,0 +1 @@ +wip diff --git a/packages/terminal-tests/Cargo.toml b/packages/terminal-tests/Cargo.toml new file mode 100644 index 0000000..b3b7165 --- /dev/null +++ b/packages/terminal-tests/Cargo.toml @@ -0,0 +1,26 @@ +[package] +name = "terminal-tests" +version = "0.0.0" +edition = "2024" +publish = false + +[dependencies] +shell-words = "1.1.1" + +[dependencies.cu] +package = "pistonite-cu" +path = "../copper" + +[[bin]] +name = "terminal-tests" +path = "src/main.rs" +required-features = ["bin"] + +[features] +default = ["bin"] +bin = ["cu/cli", "cu/process", "cu/coroutine-heavy", "cu/json"] +common = ["cu/__test", "cu/cli"] + +__test-print_levels = [] +__test-prompt = ["cu/prompt"] +__test-spinner = ["cu/derive", "cu/prompt"] diff --git a/packages/terminal-tests/Taskfile.yml b/packages/terminal-tests/Taskfile.yml new file mode 100644 index 0000000..0731d23 --- /dev/null +++ b/packages/terminal-tests/Taskfile.yml @@ -0,0 +1,16 @@ +version: '3' + +includes: + cargo: + taskfile: ../mono-dev/task/cargo.yaml + internal: true + optional: true + +tasks: + run: + - cargo run --features bin -- {{.CLI_ARGS}} + check: + - task: cargo:clippy-all + - task: cargo:fmt-check + fix: + - task: cargo:fmt-fix diff --git a/packages/terminal-tests/examples/print_levels.rs b/packages/terminal-tests/examples/print_levels.rs new file mode 100644 index 0000000..e0da13a --- /dev/null +++ b/packages/terminal-tests/examples/print_levels.rs @@ -0,0 +1,24 @@ +// $ -qq +// $ -q +// $ +// $ -v +// $ -vv +// $ --color=always -qq +// $ --color=always -q +// $ --color=always +// $ --color=always -v +// $ --color=always -vv + +#[cu::cli] +fn main(_: cu::cli::Flags) -> cu::Result<()> { + cu::info!( + "this is an info messagenmultilineaa 你好 sldkfjals🤖kdjflkasjdflkjasldkfjaklsdjflkjasldkfjlaksjdflkajsdklfjlaksjdfkljasldkfjlasldkjflaskdjflaksjdlfkajsldkfjkasjdlfkjaskldjflajsdlkfjlaskjdfklajsdf" + ); + cu::warn!("this is a warn message\n"); + cu::error!("this is error message\n\n"); + cu::debug!("this is debug message\n2\n\n"); + cu::trace!("this is trace message\n\n2\n"); + cu::print!("today's weather is {}", "good"); + cu::hint!("today's weather is {}", "ok"); + Ok(()) +} diff --git a/packages/terminal-tests/examples/prompt.rs b/packages/terminal-tests/examples/prompt.rs new file mode 100644 index 0000000..933ea78 --- /dev/null +++ b/packages/terminal-tests/examples/prompt.rs @@ -0,0 +1,23 @@ +// $ -y --non-interactive +// $ --non-interactive +// $ -y < prompt-rust.txt +// $ < prompt-y-rust.txt +// $ < prompt-y-json.txt +// $ < prompt-n.txt +// $ < prompt-xn.txt + +#[cu::cli] +fn main(_: cu::cli::Flags) -> cu::Result<()> { + cu::hint!("testing prompts"); + if !cu::yesno!("continue?")? { + cu::warn!("you chose to not continue!"); + return Ok(()); + } + let answer = cu::prompt!("what's your favorite programming language?")?; + cu::info!("you answered: {answer}"); + if answer != "rust" { + cu::bail!("the answer is incorrect"); + } + cu::info!("the answer is correct"); + Ok(()) +} diff --git a/packages/terminal-tests/examples/spinner.rs b/packages/terminal-tests/examples/spinner.rs new file mode 100644 index 0000000..4a24869 --- /dev/null +++ b/packages/terminal-tests/examples/spinner.rs @@ -0,0 +1,101 @@ +// $- 0 +// $- 1 + +use std::thread; +use std::time::Duration; + +use cu::pre::*; + +// spinner tests are skipped since the output can be unstable, +// depending on how the printing thread is scheduled + +#[derive(clap::Parser, Clone, AsRef)] +struct Args { + case: usize, + #[clap(flatten)] + #[as_ref] + inner: cu::cli::Flags, +} +#[cu::cli] +fn main(args: Args) -> cu::Result<()> { + cu::lv::disable_print_time(); + static CASES: &[fn() -> cu::Result<()>] = &[test_case_1, test_case_2]; + CASES[args.case]() +} + +fn test_case_1() -> cu::Result<()> { + // 3 sequential bars + { + // bar with message + let bar = cu::progress("unbounded").spawn(); + cu::progress!(bar, "message1"); + sleep_tick(); + cu::progress!(bar, "message2"); + sleep_tick(); + cu::progress!(bar, "message3"); + sleep_tick(); + bar.done(); + } + { + // bar with progress + let bar = cu::progress("finite").total(3).spawn(); + cu::progress!(bar += 1, "message1"); + sleep_tick(); + cu::progress!(bar += 1, "message2"); + sleep_tick(); + cu::progress!(bar += 1, "message3"); + sleep_tick(); + } + { + // bar with no keep + let bar = cu::progress("finite, nokeep").keep(false).spawn(); + cu::progress!(bar = 1, "message1"); + sleep_tick(); + cu::progress!(bar = 2, "message2"); + sleep_tick(); + cu::progress!(bar = 3, "message3"); + sleep_tick(); + } + Ok(()) +} + +fn test_case_2() -> cu::Result<()> { + { + let bar2 = cu::progress("This takes 5 seconds").total(20).spawn(); + let bar = bar2.child("This is unbounded").spawn(); + // make some fake hierarchy + let bar3 = bar.child("level 2").total(3).keep(true).spawn(); + let bar4 = bar3.child("level 3").total(7).spawn(); + let bar5 = bar2.child("last").total(9).keep(true).spawn(); + for i in 0..10 { + cu::progress!(bar, "step {i}"); + cu::progress!(bar2 = i, "step {i}"); + cu::progress!(bar3 += 1, "step {i}"); + cu::progress!(bar4 += 1, "step {i}"); + cu::progress!(bar5 += 1, "step {i}"); + cu::debug!("this is debug message\n"); + sleep_tick(); + + if i == 5 { + cu::prompt!("what's your favorite fruit?")?; + } + } + drop(bar4); + drop(bar5); + bar.done(); + for i in 0..10 { + cu::progress!(bar2 += 1, "step {}", i + 10); + sleep_tick(); + cu::print!("doing stuff"); + } + cu::progress!(bar2 += 1, "last step"); + } + + cu::print!("bars done"); + + Ok(()) +} + +fn sleep_tick() { + thread::sleep(Duration::from_secs(1)); +} diff --git a/packages/terminal-tests/input/prompt-n.txt b/packages/terminal-tests/input/prompt-n.txt new file mode 100644 index 0000000..0cb74c0 --- /dev/null +++ b/packages/terminal-tests/input/prompt-n.txt @@ -0,0 +1,2 @@ +n + diff --git a/packages/terminal-tests/input/prompt-rust.txt b/packages/terminal-tests/input/prompt-rust.txt new file mode 100644 index 0000000..9583d3b --- /dev/null +++ b/packages/terminal-tests/input/prompt-rust.txt @@ -0,0 +1,2 @@ +rust + diff --git a/packages/terminal-tests/input/prompt-xn.txt b/packages/terminal-tests/input/prompt-xn.txt new file mode 100644 index 0000000..6ec8811 --- /dev/null +++ b/packages/terminal-tests/input/prompt-xn.txt @@ -0,0 +1,2 @@ +alsdkfjalksdjf +n diff --git a/packages/terminal-tests/input/prompt-y-json.txt b/packages/terminal-tests/input/prompt-y-json.txt new file mode 100644 index 0000000..46b197a --- /dev/null +++ b/packages/terminal-tests/input/prompt-y-json.txt @@ -0,0 +1,3 @@ +y +json + diff --git a/packages/terminal-tests/input/prompt-y-rust.txt b/packages/terminal-tests/input/prompt-y-rust.txt new file mode 100644 index 0000000..9af058b --- /dev/null +++ b/packages/terminal-tests/input/prompt-y-rust.txt @@ -0,0 +1,3 @@ +y +rust + diff --git a/packages/terminal-tests/output/print_levels-0.txt b/packages/terminal-tests/output/print_levels-0.txt new file mode 100644 index 0000000..c6c88bf --- /dev/null +++ b/packages/terminal-tests/output/print_levels-0.txt @@ -0,0 +1,8 @@ +$ -qq +STDOUT >>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>> +^>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>> +^>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>> +E] this is error message^LF + | ^LF +:: today's weather is good^LF +H] today's weather is ok^LF +^>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>> +^>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>> +I] this is an info messagenmultilineaa \xE4\xBD\xA0\xE5\xA5\xBD sldkfjals\xF0\x9F\xA4\x96kdjfl^LF + | kasjdflkjasldkfjaklsdjflkjasldkfjlaksjdflkajsdklfjlaksjdf^LF + | kljasldkfjlasldkjflaskdjflaksjdlfkajsldkfjkasjdlfkjaskldj^LF + | flajsdlkfjlaskjdfklajsdf^LF +W] this is a warn message^LF +E] this is error message^LF + | ^LF +:: today's weather is good^LF +H] today's weather is ok^LF +I] finished in 0.00s^LF +^>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>> +^>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>> +I] this is an info messagenmultilineaa \xE4\xBD\xA0\xE5\xA5\xBD sldkfjals\xF0\x9F\xA4\x96kdjfl^LF + | kasjdflkjasldkfjaklsdjflkjasldkfjlaksjdflkajsdklfjlaksjdf^LF + | kljasldkfjlasldkjflaskdjflaksjdlfkajsldkfjkasjdlfkjaskldj^LF + | flajsdlkfjlaskjdfklajsdf^LF +W] this is a warn message^LF +E] this is error message^LF + | ^LF +D] this is debug message^LF + | 2^LF + | ^LF +:: today's weather is good^LF +H] today's weather is ok^LF +I] finished in 0.00s^LF +^>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>> +^>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>> +I] this is an info messagenmultilineaa \xE4\xBD\xA0\xE5\xA5\xBD sldkfjals\xF0\x9F\xA4\x96kdjfl^LF + | kasjdflkjasldkfjaklsdjflkjasldkfjlaksjdflkajsdklfjlaksjdf^LF + | kljasldkfjlasldkjflaskdjflaksjdlfkajsldkfjkasjdlfkjaskldj^LF + | flajsdlkfjlaskjdfklajsdf^LF +W] this is a warn message^LF +E] this is error message^LF + | ^LF +D] this is debug message^LF + | 2^LF + | ^LF +*] [print_levels print_levels.rs:20] this is trace message^LF + | ^LF + | 2^LF +:: today's weather is good^LF +H] today's weather is ok^LF +I] finished in 0.00s^LF +^>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>> +^>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>> +^>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>> +^>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>> +\x1B[91mE]\x1B[91m this is error message^LF +\x1B[90m | \x1B[91m^LF +\x1B[90m::\x1B[0m today's weather is good^LF +\x1B[96mH\x1B[90m]\x1B[93m today's weather is ok^LF +^>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>> +^>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>> +\x1B[92mI\x1B[90m]\x1B[0m this is an info messagenmultilineaa \xE4\xBD\xA0\xE5\xA5\xBD sldkfjals\xF0\x9F\xA4\x96kdjfl^LF +\x1B[90m | \x1B[0mkasjdflkjasldkfjaklsdjflkjasldkfjlaksjdflkajsdklfjlaksjdf^LF +\x1B[90m | \x1B[0mkljasldkfjlasldkjflaskdjflaksjdlfkajsldkfjkasjdlfkjaskldj^LF +\x1B[90m | \x1B[0mflajsdlkfjlaskjdfklajsdf^LF +\x1B[93mW]\x1B[93m this is a warn message^LF +\x1B[91mE]\x1B[91m this is error message^LF +\x1B[90m | \x1B[91m^LF +\x1B[90m::\x1B[0m today's weather is good^LF +\x1B[96mH\x1B[90m]\x1B[93m today's weather is ok^LF +\x1B[92mI\x1B[90m]\x1B[0m finished in 0.00s^LF +^>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>> +^>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>> +\x1B[92mI\x1B[90m]\x1B[0m this is an info messagenmultilineaa \xE4\xBD\xA0\xE5\xA5\xBD sldkfjals\xF0\x9F\xA4\x96kdjfl^LF +\x1B[90m | \x1B[0mkasjdflkjasldkfjaklsdjflkjasldkfjlaksjdflkajsdklfjlaksjdf^LF +\x1B[90m | \x1B[0mkljasldkfjlasldkjflaskdjflaksjdlfkajsldkfjkasjdlfkjaskldj^LF +\x1B[90m | \x1B[0mflajsdlkfjlaskjdfklajsdf^LF +\x1B[93mW]\x1B[93m this is a warn message^LF +\x1B[91mE]\x1B[91m this is error message^LF +\x1B[90m | \x1B[91m^LF +\x1B[90mD]\x1B[96m this is debug message^LF +\x1B[90m | \x1B[96m2^LF +\x1B[90m | \x1B[96m^LF +\x1B[90m::\x1B[0m today's weather is good^LF +\x1B[96mH\x1B[90m]\x1B[93m today's weather is ok^LF +\x1B[92mI\x1B[90m]\x1B[0m finished in 0.00s^LF +^>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>> +^>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>> +\x1B[92mI\x1B[90m]\x1B[0m this is an info messagenmultilineaa \xE4\xBD\xA0\xE5\xA5\xBD sldkfjals\xF0\x9F\xA4\x96kdjfl^LF +\x1B[90m | \x1B[0mkasjdflkjasldkfjaklsdjflkjasldkfjlaksjdflkajsdklfjlaksjdf^LF +\x1B[90m | \x1B[0mkljasldkfjlasldkjflaskdjflaksjdlfkajsldkfjkasjdlfkjaskldj^LF +\x1B[90m | \x1B[0mflajsdlkfjlaskjdfklajsdf^LF +\x1B[93mW]\x1B[93m this is a warn message^LF +\x1B[91mE]\x1B[91m this is error message^LF +\x1B[90m | \x1B[91m^LF +\x1B[90mD]\x1B[96m this is debug message^LF +\x1B[90m | \x1B[96m2^LF +\x1B[90m | \x1B[96m^LF +\x1B[95m*]\x1B[95m [print_levels print_levels.rs:20] this is trace message^LF +\x1B[90m | \x1B[95m^LF +\x1B[90m | \x1B[95m2^LF +\x1B[90m::\x1B[0m today's weather is good^LF +\x1B[96mH\x1B[90m]\x1B[93m today's weather is ok^LF +\x1B[92mI\x1B[90m]\x1B[0m finished in 0.00s^LF +^>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>> +^>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>> +H] testing prompts^LF +E] fatal: prompt not allowed with --non-interactive^LF +I] finished in 0.00s^LF +^>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>> +^>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>> +H] testing prompts^LF +E] fatal: prompt not allowed with --non-interactive^LF +I] finished in 0.00s^LF +^>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>> +^>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>> +H] testing prompts^LF +^CR +\x1B[K!] what's your favorite programming language?^LF +-: ^CR +\x1B[K\x1B[1A\x1B[K!] what's your favorite programming language?^LF +^CR +\x1B[KI] you answered: rust^LF +I] the answer is correct^LF +I] finished in 0.00s^LF +^>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>> +^>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>> +H] testing prompts^LF +^CR +\x1B[K!] continue? [y/n]^LF +-: ^CR +\x1B[K\x1B[1A\x1B[K!] continue? [y/n]^LF +^CR +\x1B[K^CR +\x1B[K!] what's your favorite programming language?^LF +-: ^CR +\x1B[K\x1B[1A\x1B[K!] what's your favorite programming language?^LF +^CR +\x1B[KI] you answered: rust^LF +I] the answer is correct^LF +I] finished in 0.00s^LF +^>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>> +^>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>> +H] testing prompts^LF +^CR +\x1B[K!] continue? [y/n]^LF +-: ^CR +\x1B[K\x1B[1A\x1B[K!] continue? [y/n]^LF +^CR +\x1B[K^CR +\x1B[K!] what's your favorite programming language?^LF +-: ^CR +\x1B[K\x1B[1A\x1B[K!] what's your favorite programming language?^LF +^CR +\x1B[KI] you answered: json^LF +E] fatal: the answer is incorrect^LF +I] finished in 0.00s^LF +^>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>> +^>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>> +H] testing prompts^LF +^CR +\x1B[K!] continue? [y/n]^LF +-: ^CR +\x1B[K\x1B[1A\x1B[K!] continue? [y/n]^LF +^CR +\x1B[KW] you chose to not continue!^LF +I] finished in 0.00s^LF +^>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>> +^>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>> +H] testing prompts^LF +^CR +\x1B[K!] continue? [y/n]^LF +-: ^CR +\x1B[K\x1B[1A\x1B[K!] continue? [y/n]^LF +^CR +\x1B[KH] please enter yes or no^LF +^CR +\x1B[K!] continue? [y/n]^LF +-: ^CR +\x1B[K\x1B[1A\x1B[K!] continue? [y/n]^LF +^CR +\x1B[KW] you chose to not continue!^LF +I] finished in 0.00s^LF +^>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>> +^, + /// Update snapshot + #[clap(short, long)] + update: bool, + /// Display the output of the ith-case in the example instead of testing + #[clap(short, long, requires = "test", conflicts_with = "update")] + display: Option, + + /// Prompt instead of using pre-configured stdin + #[clap(short = 'i', long, requires = "test", conflicts_with = "update")] + inherit_stdin: bool, + + #[clap(flatten)] + flags: cu::cli::Flags, +} + +#[cu::cli(flags = "flags")] +async fn main(args: Cli) -> cu::Result<()> { + match args.test { + None => { + let test_targets = cu::check!(find_tests(), "failed to find tests")?; + run_test_targets(test_targets, args.update).await?; + } + Some(example_name) => { + let path = crate_dir() + .join("examples") + .join(format!("{example_name}.rs")); + let test_cases = cu::check!( + parse_test_cases(&path), + "failed to parse test case from '{example_name}'" + )?; + if let Some(case_i) = args.display { + let test_case = cu::check!( + test_cases.get(case_i), + "index out of bound of test cases: {case_i}" + )?; + let childargs = &test_case.args; + let stdin = test_case.stdin.as_ref().cloned().unwrap_or_default(); + let feature = format!("__test-{example_name},common"); + cu::print!("TEST OUTPUT >>>>>>>>>>>>>>>>>>>>>>>>>>"); + let command_builder = cu::which("cargo")? + .command() + // don't include warnings in the output + .env("RUSTFLAGS", "-Awarnings") + .args([ + "run", + "-q", + "--example", + &example_name, + "--no-default-features", + "--features", + &feature, + "--", + ]) + .args(childargs) + .stdout_inherit() + .stderr_inherit(); + let exit_status = if args.inherit_stdin { + command_builder.stdin_inherit().co_wait().await? + } else { + command_builder + .stdin(cu::pio::write(stdin)) + .co_wait() + .await? + }; + cu::print!("TEST OUTPUT <<<<<<<<<<<<<<<<<<<<<<<<<<"); + cu::print!("STATUS: {exit_status}"); + } else { + let test_target = TestTarget { + example_name, + test_cases, + }; + run_test_targets(vec![test_target], args.update).await?; + } + } + } + Ok(()) +} + +struct TestTarget { + example_name: String, + test_cases: Vec, +} + +struct TestCase { + stdin: Option>, + args: Vec, + skip: bool, +} + +fn find_tests() -> cu::Result> { + let path = crate_dir().join("examples"); + + // find example entry points + let mut test_targets = vec![]; + let dir = cu::fs::read_dir(path)?; + for entry in dir { + let entry = entry?; + let name = entry.file_name(); + let name_str = cu::check!(name.to_str(), "not utf8")?; + let Some(example_name) = name_str.strip_suffix(".rs") else { + continue; // ignore non *.rs file + }; + let test_cases = parse_test_cases(&entry.path())?; + let test_target = TestTarget { + example_name: example_name.to_string(), + test_cases, + }; + test_targets.push(test_target); + } + + Ok(test_targets) +} + +fn parse_test_cases(path: &Path) -> cu::Result> { + let file = cu::fs::read_string(path)?; + let mut test_cases = vec![]; + for line in file.lines() { + let Some(line) = line.strip_prefix("// $") else { + break; + }; + let (line, skip) = match line.strip_prefix("-") { + None => (line, false), + Some(line) => (line, true), + }; + let mut args = cu::check!( + shell_words::split(line.trim()), + "failed to parse command line: {line}" + )?; + let mut stdin = None; + if args.len() >= 2 { + if let Some("<") = args.get(args.len() - 2).map(|x| x.as_str()) { + let stdin_path = args.pop().unwrap(); + let stdin_path = crate_dir().join("input").join(stdin_path); + stdin = Some(cu::check!( + cu::fs::read(stdin_path), + "failed to read stdin for test case" + )?); + args.pop(); + } + } + test_cases.push(TestCase { args, stdin, skip }); + } + Ok(test_cases) +} + +async fn run_test_targets(targets: Vec, update: bool) -> cu::Result<()> { + let build_bar = cu::progress("building test targets") + .total(targets.len()) + .eta(false) + .spawn(); + // build one at a time + let build_pool = cu::co::pool(1); + let mut build_handles = Vec::with_capacity(targets.len()); + let mut total_tests = 0; + for target in &targets { + total_tests += target.test_cases.len(); + if target.test_cases.is_empty() { + cu::warn!("no test case found in '{}'", target.example_name); + } + // cargo build --example X --features __test-X + let example_name = target.example_name.clone(); + let build_bar = Arc::clone(&build_bar); + let handle = build_pool.spawn(async move { + let feature = format!("__test-{example_name},common"); + let (child, bar) = cu::which("cargo")? + .command() + .args([ + "build", + "--example", + &example_name, + "--no-default-features", + "--features", + &feature, + ]) + .preset( + cu::pio::cargo(format!("building {example_name}")) + .configure_spinner(|bar| bar.parent(Some(Arc::clone(&build_bar)))), + ) + .co_spawn() + .await?; + child.co_wait_nz().await?; + bar.done(); + cu::progress!(build_bar += 1); + cu::Ok(example_name) + }); + build_handles.push(handle); + } + drop(build_bar); + + let test_bar = cu::progress("running tests") + .total(total_tests) + .max_display_children(10) + .eta(false) + .spawn(); + let test_pool = cu::co::pool(-2); + let mut test_handles = Vec::with_capacity(total_tests); + let mut build_set = cu::co::set(build_handles); + while let Some(result) = build_set.next().await { + let example_name = result??; + let target = cu::check!( + targets.iter().find(|x| x.example_name == example_name), + "unexpected: cannot find test cases" + )?; + + for (index, test_case) in target.test_cases.iter().enumerate() { + let example_name = example_name.clone(); + if test_case.skip { + cu::warn!("skipping {example_name}-{index}"); + cu::progress!(test_bar += 1); + continue; + } + let args = test_case.args.clone(); + let stdin = test_case.stdin.clone().unwrap_or_default(); + let test_bar = Arc::clone(&test_bar); + + let handle = test_pool.spawn(async move { + let feature = format!("__test-{example_name},common"); + let command = shell_words::join(&args); + let child_bar = test_bar.child(format!("{example_name}: {command}")).spawn(); + let (mut child, stdout, stderr) = cu::which("cargo")? + .command() + // don't include warnings in the output + .env("RUSTFLAGS", "-Awarnings") + .args([ + "run", + "-q", + "--example", + &example_name, + "--no-default-features", + "--features", + &feature, + "--", + ]) + .args(args) + .stdout(cu::pio::buffer()) + .stderr(cu::pio::buffer()) + .stdin(cu::pio::write(stdin)) + .co_spawn() + .await?; + let status = child.co_wait_timeout(Duration::from_secs(10)).await?; + if status.is_none() { + child.co_kill().await?; + } + child_bar.done(); + let stdout = stdout.co_join().await??; + let stderr = stderr.co_join().await??; + let output = decode_output_streams(&command, stdout, stderr, status); + + cu::progress!(test_bar += 1); + cu::Ok((example_name, command, index, output)) + }); + test_handles.push(handle); + } + } + + let mut test_set = cu::co::set(test_handles); + let mut failures = vec![]; + while let Some(result) = test_set.next().await { + let (example_name, command, index, output) = result??; + let result = verify_output(&example_name, &command, index, &output, update); + if let Err(error) = result { + failures.push(error.to_string()); + cu::progress!(test_bar, "{} failed", failures.len()); + } + } + drop(test_bar); + + if failures.is_empty() { + cu::info!("all tests passed"); + return Ok(()); + } + + for f in &failures { + cu::warn!("test failed: {f}"); + } + + cu::bail!("{} tests failed", failures.len()); +} + +fn decode_output_streams( + command: &str, + stdout: Vec, + stderr: Vec, + status: Option, +) -> String { + use std::fmt::Write as _; + let mut out = String::new(); + out.push_str("$ "); + out.push_str(command); + out.push('\n'); + out.push_str("STDOUT >>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>\n"); + decode_output_stream(&mut out, &stdout); + out.push_str("^>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>\n"); + decode_output_stream(&mut out, &stderr); + out.push_str("^ { + let _ = writeln!(out, "status: {status}"); + } + None => { + let _ = writeln!(out, "timed out"); + } + } + + out +} + +fn decode_output_stream(out: &mut String, buffer: &[u8]) { + for byte in buffer.iter().copied() { + match byte { + b' '..=b'~' => out.push(byte as char), + b'\r' => out.push_str("^CR\n"), + b'\n' => out.push_str("^LF\n"), + byte => out.push_str(&format!("\\x{byte:02X}")), + } + } +} + +fn verify_output( + example_name: &str, + command: &str, + index: usize, + output: &str, + update: bool, +) -> cu::Result<()> { + let mut output_path = crate_dir().join("output"); + let file_name = format!("{example_name}-{index}.txt"); + output_path.push(&file_name); + + if !output_path.exists() { + cu::info!("new snapshot: {example_name}: {command}"); + cu::fs::write(output_path, output)?; + return Ok(()); + } + + let expected_output = cu::fs::read_string(&output_path)?; + // normalize line ending for windows users + let expected_output = expected_output.lines().collect::>().join("\n"); + if expected_output.trim() == output.trim() { + cu::info!("pass: {example_name}: {command}"); + return Ok(()); + } + + if !update { + cu::error!("fail: {example_name}: {command}"); + let mut wip_path = crate_dir().join("wip"); + wip_path.push(&file_name); + cu::fs::write(wip_path, output)?; + cu::bail!("output mismatch: {file_name}"); + } + + cu::fs::write(output_path, output)?; + cu::info!("updated snapshot: {example_name}: {command}"); + Ok(()) +} + +fn crate_dir() -> &'static Path { + env!("CARGO_MANIFEST_DIR").as_ref() +}