diff --git a/.idea/icon.svg b/.idea/icon.svg new file mode 100644 index 000000000..5d230ee5d --- /dev/null +++ b/.idea/icon.svg @@ -0,0 +1,50 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/.idea/r3bl-open-core.iml b/.idea/r3bl-open-core.iml index dbd55ae8f..ad3ff7b3b 100644 --- a/.idea/r3bl-open-core.iml +++ b/.idea/r3bl-open-core.iml @@ -20,6 +20,8 @@ + + diff --git a/.vscode/settings.json b/.vscode/settings.json index 3bcb83893..a34e5e753 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -20,6 +20,7 @@ "Blackbox", "BOOKM", "boop", + "callsite", "CBOR", "chrono", "cicd", diff --git a/CHANGELOG.md b/CHANGELOG.md index e9744723a..898a7cbc2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -61,6 +61,10 @@ - [v0.0.3 2024-09-12](#v003-2024-09-12) - [v0.0.2 2024-07-13](#v002-2024-07-13) - [v0.0.1 2024-07-12](#v001-2024-07-12) +- [r3bl_log](#r3bl_log) + - [v_next_release_log](#v_next_release_r3bl_log) +- [r3bl_script](#r3bl_script) + - [v_next_release_script](#v_next_release_r3bl_script) - [r3bl_terminal_async](#r3bl_terminal_async) - [v0.6.0 2024-10-21](#v060-2024-10-21) - [v0.5.7 2024-09-12](#v057-2024-09-12) @@ -778,11 +782,20 @@ Changed: replace them with doc comments that compile successfully. - Added: + - `UnicodeString` now implements `std::fmt::Display`, so it is no longer necessary to + use `UnicodeString.string` to get to the underlying string. This is just more + ergonomic. This is added in `convert.rs`. More work needs to be done to introduce + different types for holding "width / height" aka "column / row counts", and "column / + row index". Currently there is a `Size` struct, and `Position` struct, but most of the + APIs don't really use one or another, they just use `ChUnit` directly. - `lolcat_api` enhancements that now allow for an optional default style to be passed in to `ColorWheel::lolcat_into_string` and `ColorWheel::colorize_into_string`, that will be applied to the generated lolcat output. - `convert_to_ansi_color_styles` module that adds the ability to convert a `TuiStyle` into a `Vec` of `r3bl_ansi_term::Style`. + - `string_helpers.rs` has new utility functions to check whether a given string contains + any ANSI escape codes `contains_ansi_escape_sequence`. And to remove needless escaped + `\"` characters from a string `remove_escaped_quotes`. - A new declarative macro `create_global_singleton!` that takes a struct (which must implement `Default` trait) and allows it to be simply turned into a singleton. - You can still use the struct directly. Or just use the supplied generated associated @@ -986,6 +999,24 @@ links for this release: [crates.io](https://crates.io/crates/r3bl_test_fixtures) crates in this monorepo to use them. These fixtures are migrated from `r3bl_terminal_async` crate, where they were gestated, before being graduated for use by the entire monorepo. +## `r3bl_log` + +### v_next_release_r3bl_log + +This is the first release of this crate. It is a top level crate in the `r3bl-open-core` +that is meant to hold all the logging related functionality for all the other crates in +this monorepo. It uses `tracing` under the covers to provide structured logging. It also +provides a custom formatter that is a `tracing-subscriber` crate plugin. + +## `r3bl_script` + +### v_next_release_r3bl_script + +This is the first release of this crate. It is a top level crate in the `r3bl-open-core` +that is meant to hold all the scripting related functionality for all the other crates in +this monorepo. It provides a way to run scripts in a safe and secure way, that is meant to +be a replacement for writing scripts in `fish` or `bash` or `nushell` syntax. + ## `r3bl_terminal_async` ### v0.6.0 (2024-10-21) diff --git a/Cargo.lock b/Cargo.lock index efc877bec..ac47c21e3 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -134,7 +134,7 @@ checksum = "c7c24de15d275a1ecfd47a380fb4d5ec9bfe0933f309ed5e705b775596a3574d" dependencies = [ "proc-macro2", "quote", - "syn 2.0.82", + "syn 2.0.90", ] [[package]] @@ -264,9 +264,9 @@ checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd" [[package]] name = "chrono" -version = "0.4.38" +version = "0.4.39" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a21f936df1771bf62b77f047b726c4625ff2e8aa607c01ec06e5a05bd8463401" +checksum = "7e36cc9d416881d2e24f9a963be5fb1cd90966419ac844274161d10488b3e825" dependencies = [ "android-tzdata", "iana-time-zone", @@ -297,7 +297,7 @@ dependencies = [ "anstyle", "clap_lex", "strsim", - "terminal_size 0.4.0", + "terminal_size", ] [[package]] @@ -309,7 +309,7 @@ dependencies = [ "heck", "proc-macro2", "quote", - "syn 2.0.82", + "syn 2.0.90", ] [[package]] @@ -700,7 +700,7 @@ checksum = "162ee34ebcb7c64a8abebc059ce0fee27c2262618d7b60ed8faf72fef13c3650" dependencies = [ "proc-macro2", "quote", - "syn 2.0.82", + "syn 2.0.90", ] [[package]] @@ -1061,7 +1061,7 @@ dependencies = [ "serde", "serde_json", "sled", - "thiserror", + "thiserror 1.0.64", "toml", ] @@ -1166,9 +1166,9 @@ dependencies = [ [[package]] name = "miette" -version = "7.2.0" +version = "7.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4edc8853320c2a0dab800fbda86253c8938f6ea88510dc92c5f1ed20e794afc1" +checksum = "317f146e2eb7021892722af37cf1b971f0a70c8406f487e24952667616192c64" dependencies = [ "backtrace", "backtrace-ext", @@ -1178,21 +1178,21 @@ dependencies = [ "supports-color", "supports-hyperlinks", "supports-unicode", - "terminal_size 0.3.0", + "terminal_size", "textwrap", - "thiserror", + "thiserror 1.0.64", "unicode-width 0.1.14", ] [[package]] name = "miette-derive" -version = "7.2.0" +version = "7.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dcf09caffaac8068c346b6df2a7fc27a177fd20b39421a39ce0a211bde679a6c" +checksum = "23c9b935fbe1d6cbd1dac857b54a688145e2d93f48db36010514d0f612d0ad67" dependencies = [ "proc-macro2", "quote", - "syn 2.0.82", + "syn 2.0.90", ] [[package]] @@ -1402,7 +1402,7 @@ checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c" dependencies = [ "proc-macro2", "quote", - "syn 2.0.82", + "syn 2.0.90", ] [[package]] @@ -1537,7 +1537,7 @@ dependencies = [ "phf_shared", "proc-macro2", "quote", - "syn 2.0.82", + "syn 2.0.90", ] [[package]] @@ -1634,9 +1634,9 @@ dependencies = [ [[package]] name = "proc-macro2" -version = "1.0.88" +version = "1.0.92" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7c3a7fc5db1e57d5a779a352c8cdb57b29aa4c40cc69c3a68a7fedc815fbf2f9" +checksum = "37d3544b3f2748c54e147655edb5025752e2303145b5aefb3c3ea2c78b973bb0" dependencies = [ "unicode-ident", ] @@ -1673,6 +1673,7 @@ dependencies = [ "r3bl_analytics_schema", "r3bl_ansi_color", "r3bl_core", + "r3bl_log", "r3bl_macro", "r3bl_tui", "r3bl_tuify", @@ -1682,7 +1683,7 @@ dependencies = [ "serde", "serde_json", "serial_test", - "thiserror", + "thiserror 1.0.64", "tokio", "tracing", "tracing-appender", @@ -1713,7 +1714,6 @@ dependencies = [ name = "r3bl_core" version = "0.10.0" dependencies = [ - "assert_cmd", "async-stream", "bincode", "chrono", @@ -1736,8 +1736,7 @@ dependencies = [ "strip-ansi", "strum", "strum_macros", - "tempfile", - "thiserror", + "thiserror 1.0.64", "time", "tokio", "tracing", @@ -1748,6 +1747,29 @@ dependencies = [ "unicode-width 0.2.0", ] +[[package]] +name = "r3bl_log" +version = "0.1.0" +dependencies = [ + "assert_cmd", + "chrono", + "crossterm", + "miette", + "pretty_assertions", + "r3bl_ansi_color", + "r3bl_core", + "r3bl_macro", + "r3bl_test_fixtures", + "serial_test", + "textwrap", + "thiserror 1.0.64", + "tokio", + "tracing", + "tracing-appender", + "tracing-core", + "tracing-subscriber", +] + [[package]] name = "r3bl_macro" version = "0.10.0" @@ -1757,7 +1779,31 @@ dependencies = [ "proc-macro2", "quote", "r3bl_core", - "syn 2.0.82", + "syn 2.0.90", +] + +[[package]] +name = "r3bl_script" +version = "0.1.0" +dependencies = [ + "chrono", + "crossterm", + "futures-util", + "miette", + "r3bl_ansi_color", + "r3bl_core", + "r3bl_macro", + "reqwest", + "serde_json", + "serial_test", + "strum", + "strum_macros", + "textwrap", + "thiserror 2.0.7", + "tokio", + "tracing", + "tracing-core", + "tracing-subscriber", ] [[package]] @@ -1772,12 +1818,13 @@ dependencies = [ "pretty_assertions", "r3bl_ansi_color", "r3bl_core", + "r3bl_log", "r3bl_test_fixtures", "r3bl_tui", "r3bl_tuify", "strum", "strum_macros", - "thiserror", + "thiserror 1.0.64", "tokio", "tracing", "tracing-appender", @@ -1801,7 +1848,7 @@ dependencies = [ "strip-ansi-escapes", "strum", "strum_macros", - "thiserror", + "thiserror 1.0.64", "tokio", "tokio-test", "tracing", @@ -1823,6 +1870,7 @@ dependencies = [ "pretty_assertions", "r3bl_ansi_color", "r3bl_core", + "r3bl_log", "r3bl_macro", "r3bl_terminal_async", "r3bl_test_fixtures", @@ -1835,7 +1883,7 @@ dependencies = [ "strum_macros", "syntect", "textwrap", - "thiserror", + "thiserror 1.0.64", "tokio", "tracing", "tracing-appender", @@ -1854,6 +1902,7 @@ dependencies = [ "pretty_assertions", "r3bl_ansi_color", "r3bl_core", + "r3bl_log", "reedline", "serde", "serde_json", @@ -1921,7 +1970,7 @@ checksum = "ba009ff324d1fc1b900bd1fdb31564febe58a8ccc8a6fdbb93b543d33b13ca43" dependencies = [ "getrandom", "libredox", - "thiserror", + "thiserror 1.0.64", ] [[package]] @@ -1939,7 +1988,7 @@ dependencies = [ "strip-ansi-escapes", "strum", "strum_macros", - "thiserror", + "thiserror 1.0.64", "unicode-segmentation", "unicode-width 0.1.14", ] @@ -1975,9 +2024,9 @@ checksum = "2b15c43186be67a4fd63bee50d0303afffcef381492ebe2c5d87f324e1b8815c" [[package]] name = "reqwest" -version = "0.12.8" +version = "0.12.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f713147fbe92361e52392c73b8c9e48c04c6625bce969ef54dc901e58e042a7b" +checksum = "a77c62af46e79de0a562e1a9849205ffcb7fc1238876e9bd743357570e04046f" dependencies = [ "base64", "bytes", @@ -2052,9 +2101,9 @@ dependencies = [ [[package]] name = "rustls" -version = "0.23.15" +version = "0.23.20" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5fbb44d7acc4e873d613422379f69f237a1b141928c02f6bc6ccfddddc2d7993" +checksum = "5065c3f250cbd332cd894be57c40fa52387247659b14a2d6041d121547903b1b" dependencies = [ "once_cell", "rustls-pki-types", @@ -2186,14 +2235,14 @@ checksum = "243902eda00fad750862fc144cea25caca5e20d615af0a81bee94ca738f1df1f" dependencies = [ "proc-macro2", "quote", - "syn 2.0.82", + "syn 2.0.90", ] [[package]] name = "serde_json" -version = "1.0.132" +version = "1.0.133" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d726bfaff4b320266d395898905d0eba0345aae23b54aee3a737e260fd46db03" +checksum = "c7fceb2473b9166b2294ef05efcb65a3db80803f0b03ef86a5fc88a2b85ee377" dependencies = [ "itoa", "memchr", @@ -2215,9 +2264,9 @@ dependencies = [ [[package]] name = "serial_test" -version = "3.1.1" +version = "3.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4b4b487fe2acf240a021cf57c6b2b4903b1e78ca0ecd862a71b71d2a51fed77d" +checksum = "1b258109f244e1d6891bf1053a55d63a5cd4f8f4c30cf9a1280989f80e7a1fa9" dependencies = [ "futures", "log", @@ -2229,13 +2278,13 @@ dependencies = [ [[package]] name = "serial_test_derive" -version = "3.1.1" +version = "3.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "82fe9db325bcef1fbcde82e078a5cc4efdf787e96b3b9cf45b50b529f2083d67" +checksum = "5d69265a08751de7844521fd15003ae0a888e035773ba05695c5c759a6f89eef" dependencies = [ "proc-macro2", "quote", - "syn 2.0.82", + "syn 2.0.90", ] [[package]] @@ -2441,7 +2490,7 @@ dependencies = [ "proc-macro2", "quote", "rustversion", - "syn 2.0.82", + "syn 2.0.90", ] [[package]] @@ -2484,9 +2533,9 @@ dependencies = [ [[package]] name = "syn" -version = "2.0.82" +version = "2.0.90" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "83540f837a8afc019423a8edb95b52a8effe46957ee402287f4292fae35be021" +checksum = "919d3b74a5dd0ccd15aeb8f93e7006bd9e14c295087c9896a110f490752bcf31" dependencies = [ "proc-macro2", "quote", @@ -2519,7 +2568,7 @@ dependencies = [ "serde", "serde_derive", "serde_json", - "thiserror", + "thiserror 1.0.64", "walkdir", "yaml-rust", ] @@ -2558,16 +2607,6 @@ dependencies = [ "windows-sys 0.59.0", ] -[[package]] -name = "terminal_size" -version = "0.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "21bebf2b7c9e0a515f6e0f8c51dc0f8e4696391e6f1ff30379559f8365fb0df7" -dependencies = [ - "rustix", - "windows-sys 0.48.0", -] - [[package]] name = "terminal_size" version = "0.4.0" @@ -2602,7 +2641,7 @@ dependencies = [ "cfg-if", "proc-macro2", "quote", - "syn 2.0.82", + "syn 2.0.90", ] [[package]] @@ -2613,7 +2652,7 @@ checksum = "5c89e72a01ed4c579669add59014b9a524d609c0c88c6a585ce37485879f6ffb" dependencies = [ "proc-macro2", "quote", - "syn 2.0.82", + "syn 2.0.90", "test-case-core", ] @@ -2634,7 +2673,16 @@ version = "1.0.64" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d50af8abc119fb8bb6dbabcfa89656f46f84aa0ac7688088608076ad2b459a84" dependencies = [ - "thiserror-impl", + "thiserror-impl 1.0.64", +] + +[[package]] +name = "thiserror" +version = "2.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93605438cbd668185516ab499d589afb7ee1859ea3d5fc8f6b0755e1c7443767" +dependencies = [ + "thiserror-impl 2.0.7", ] [[package]] @@ -2645,7 +2693,18 @@ checksum = "08904e7672f5eb876eaaf87e0ce17857500934f4981c4a0ab2b4aa98baac7fc3" dependencies = [ "proc-macro2", "quote", - "syn 2.0.82", + "syn 2.0.90", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1d8749b4531af2117677a5fcd12b1348a3fe2b81e36e61ffeac5c4aa3273e36" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.90", ] [[package]] @@ -2706,9 +2765,9 @@ checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" [[package]] name = "tokio" -version = "1.40.0" +version = "1.42.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e2b070231665d27ad9ec9b8df639893f46727666c6767db40317fbe920a5d998" +checksum = "5cec9b21b0450273377fc97bd4c33a8acffc8c996c987a7c5b319a0083707551" dependencies = [ "backtrace", "bytes", @@ -2731,7 +2790,7 @@ checksum = "693d596312e88961bc67d7f1f97af8a70227d9f90c31bba5806eec004978d752" dependencies = [ "proc-macro2", "quote", - "syn 2.0.82", + "syn 2.0.90", ] [[package]] @@ -2746,12 +2805,11 @@ dependencies = [ [[package]] name = "tokio-rustls" -version = "0.26.0" +version = "0.26.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0c7bc40d0e5a97695bb96e27995cd3a08538541b0a846f65bba7a359f36700d4" +checksum = "5f6d0975eaace0cf0fcadee4e4aaa5da15b5c079146f2cffb67c113be122bf37" dependencies = [ "rustls", - "rustls-pki-types", "tokio", ] @@ -2809,9 +2867,9 @@ checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" [[package]] name = "tracing" -version = "0.1.40" +version = "0.1.41" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c3523ab5a71916ccf420eebdf5521fcef02141234bbc0b8a49f2fdc4544364ef" +checksum = "784e0ac535deb450455cbfa28a6f0df145ea1bb7ae51b821cf5e7927fdcfbdd0" dependencies = [ "pin-project-lite", "tracing-attributes", @@ -2825,27 +2883,27 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3566e8ce28cc0a3fe42519fc80e6b4c943cc4c8cef275620eb8dac2d3d4e06cf" dependencies = [ "crossbeam-channel", - "thiserror", + "thiserror 1.0.64", "time", "tracing-subscriber", ] [[package]] name = "tracing-attributes" -version = "0.1.27" +version = "0.1.28" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "34704c8d6ebcbc939824180af020566b01a7c01f80641264eba0999f6c2b6be7" +checksum = "395ae124c09f9e6918a2310af6038fba074bcf474ac352496d5910dd59a2226d" dependencies = [ "proc-macro2", "quote", - "syn 2.0.82", + "syn 2.0.90", ] [[package]] name = "tracing-core" -version = "0.1.32" +version = "0.1.33" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c06d3da6113f116aaee68e4d601191614c9053067f9ab7f6edbcb161237daa54" +checksum = "e672c95779cf947c5311f83787af4fa8fffd12fb27e4993211a84bdfd9610f9c" dependencies = [ "once_cell", "valuable", @@ -2864,9 +2922,9 @@ dependencies = [ [[package]] name = "tracing-subscriber" -version = "0.3.18" +version = "0.3.19" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ad0f048c97dbd9faa9b7df56362b8ebcaa52adb06b498c050d2f4e32f90a7a8b" +checksum = "e8189decb5ac0fa7bc8b96b7cb9b2701d60d48805aca84a238004d665fcc4008" dependencies = [ "nu-ansi-term 0.46.0", "sharded-slab", @@ -2975,7 +3033,7 @@ checksum = "6b91f57fe13a38d0ce9e28a03463d8d3c2468ed03d75375110ec71d93b449a08" dependencies = [ "proc-macro2", "quote", - "syn 2.0.82", + "syn 2.0.90", ] [[package]] @@ -3072,7 +3130,7 @@ dependencies = [ "once_cell", "proc-macro2", "quote", - "syn 2.0.82", + "syn 2.0.90", "wasm-bindgen-shared", ] @@ -3106,7 +3164,7 @@ checksum = "26c6ab57572f7a24a4985830b120de1594465e5d500f24afe89e16b4e833ef68" dependencies = [ "proc-macro2", "quote", - "syn 2.0.82", + "syn 2.0.90", "wasm-bindgen-backend", "wasm-bindgen-shared", ] @@ -3515,7 +3573,7 @@ checksum = "fa4f8080344d4671fb4e831a13ad1e68092748387dfc4f55e356242fae12ce3e" dependencies = [ "proc-macro2", "quote", - "syn 2.0.82", + "syn 2.0.90", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index 275291dee..c7fc6adf0 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -10,6 +10,8 @@ members = [ "test_fixtures", "tui", "tuify", + "log", + "script", ] # Make sure to keep these in sync with `run` nushell script `workspace_folders`. resolver = "2" diff --git a/cmdr/Cargo.toml b/cmdr/Cargo.toml index 6f1f949b8..d3ee1ef32 100644 --- a/cmdr/Cargo.toml +++ b/cmdr/Cargo.toml @@ -43,12 +43,13 @@ path = "src/lib.rs" [dependencies] # R3BL crates (from this mono repo). -r3bl_ansi_color = { path = "../ansi_color", version = "0.7.0" } # version is requried to publish to crates.io -r3bl_core = { path = "../core", version = "0.10.0" } # version is requried to publish to crates.io -r3bl_macro = { path = "../macro", version = "0.10.0" } # version is requried to publish to crates.io -r3bl_tui = { path = "../tui", version = "0.6.0" } # version is requried to publish to crates.io -r3bl_tuify = { path = "../tuify", version = "0.2.0" } # version is requried to publish to crates.io -r3bl_analytics_schema = { path = "../analytics_schema", version = "0.0.2" } # version is requried to publish to crates.io +r3bl_ansi_color = { path = "../ansi_color", version = "0.7.0" } # version is required to publish to crates.io +r3bl_core = { path = "../core", version = "0.10.0" } # version is required to publish to crates.io +r3bl_macro = { path = "../macro", version = "0.10.0" } # version is required to publish to crates.io +r3bl_tui = { path = "../tui", version = "0.6.0" } # version is required to publish to crates.io +r3bl_tuify = { path = "../tuify", version = "0.2.0" } # version is required to publish to crates.io +r3bl_analytics_schema = { path = "../analytics_schema", version = "0.0.2" } # version is required to publish to crates.io +r3bl_log = { path = "../log", version = "0.1.0" } # version is required to publish to crates.io # Reqwest (HTTP client). reqwest = { version = "0.12.8", features = ["json"] } diff --git a/cmdr/src/bin/edi.rs b/cmdr/src/bin/edi.rs index 386ee4645..f911ac224 100644 --- a/cmdr/src/bin/edi.rs +++ b/cmdr/src/bin/edi.rs @@ -22,12 +22,12 @@ use r3bl_ansi_color::{AnsiStyledText, Style}; use r3bl_cmdr::{edi::launcher, report_analytics, upgrade_check, AnalyticsAction}; use r3bl_core::{call_if_true, throws, - try_initialize_global_logging, ColorWheel, CommonResult, GradientGenerationPolicy, TextColorizationPolicy, UnicodeString}; +use r3bl_log::try_initialize_logging_global; use r3bl_tuify::{select_from_list, SelectionMode, StyleSheet, LIZARD_GREEN, SLATE_GRAY}; use crate::clap_config::CLIArg; @@ -42,7 +42,7 @@ async fn main() -> CommonResult<()> { // Start logging. let enable_logging = cli_arg.global_options.enable_logging; call_if_true!(enable_logging, { - try_initialize_global_logging(tracing_core::LevelFilter::DEBUG).ok(); + try_initialize_logging_global(tracing_core::LevelFilter::DEBUG).ok(); tracing::debug!("Start logging... cli_args {:?}", cli_arg); }); diff --git a/cmdr/src/bin/giti.rs b/cmdr/src/bin/giti.rs index 043423343..6ba1423b1 100644 --- a/cmdr/src/bin/giti.rs +++ b/cmdr/src/bin/giti.rs @@ -34,7 +34,8 @@ use r3bl_cmdr::{color_constants::DefaultColors::{FrozenBlue, GuardsRed, Moonligh report_analytics, upgrade_check, AnalyticsAction}; -use r3bl_core::{call_if_true, throws, try_initialize_global_logging, CommonResult}; +use r3bl_core::{call_if_true, throws, CommonResult}; +use r3bl_log::try_initialize_logging_global; use r3bl_tuify::{select_from_list_with_multi_line_header, SelectionMode, StyleSheet}; #[tokio::main] @@ -47,7 +48,7 @@ async fn main() -> CommonResult<()> { let enable_logging = cli_arg.global_options.enable_logging; call_if_true!(enable_logging, { - try_initialize_global_logging(tracing_core::LevelFilter::DEBUG).ok(); + try_initialize_logging_global(tracing_core::LevelFilter::DEBUG).ok(); tracing::debug!("Start logging... cli_args {:?}", cli_arg); }); diff --git a/core/Cargo.toml b/core/Cargo.toml index f53e794f4..a762a706d 100644 --- a/core/Cargo.toml +++ b/core/Cargo.toml @@ -96,12 +96,3 @@ paste = "1.0.15" # for assert_eq! macro pretty_assertions = "1.4.1" serial_test = "3.1.1" - -# Testing - temp files and folders. -tempfile = "3.13.0" - -# Bin targets for testing stdout and stderr. -assert_cmd = "2.0.16" -[[bin]] -name = "tracing_test_bin" -path = "src/bin/tracing_test_bin.rs" diff --git a/core/src/common/mod.rs b/core/src/common/mod.rs index cd73afe8e..0f4677724 100644 --- a/core/src/common/mod.rs +++ b/core/src/common/mod.rs @@ -20,9 +20,13 @@ pub mod common_enums; pub mod common_math; pub mod common_result_and_error; pub mod miette_setup_global_report_handler; +pub mod ordered_map; +pub mod text_default_styles; // Re-export. pub use common_enums::*; pub use common_math::*; pub use common_result_and_error::*; pub use miette_setup_global_report_handler::*; +pub use ordered_map::*; +pub use text_default_styles::*; diff --git a/core/src/common/ordered_map.rs b/core/src/common/ordered_map.rs new file mode 100644 index 000000000..1c1b6cd9b --- /dev/null +++ b/core/src/common/ordered_map.rs @@ -0,0 +1,125 @@ +/* + * Copyright (c) 2024 R3BL LLC + * All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +use std::collections::HashMap; + +#[derive(Debug, Default)] +pub struct OrderedMap { + keys: Vec, + map: HashMap, +} + +impl OrderedMap { + pub fn new() -> Self { + OrderedMap { + keys: Vec::new(), + map: HashMap::new(), + } + } + + pub fn insert(&mut self, key: K, value: V) { + if !self.map.contains_key(&key) { + self.keys.push(key.clone()); + } + self.map.insert(key, value); + } + + pub fn get(&self, key: &K) -> Option<&V> { self.map.get(key) } + + pub fn iter(&self) -> impl Iterator { + self.keys + .iter() + .filter_map(move |key| self.map.get(key).map(|value| (key, value))) + } +} + +#[cfg(test)] +mod tests_ordered_map { + use super::*; + + #[test] + fn test_ordered_map_insert() { + let mut map = OrderedMap::new(); + map.insert("key2", "value2"); + map.insert("key1", "value1"); + map.insert("key3", "value3"); + + let mut iter = map.iter(); + assert_eq!(iter.next(), Some((&"key2", &"value2"))); + assert_eq!(iter.next(), Some((&"key1", &"value1"))); + assert_eq!(iter.next(), Some((&"key3", &"value3"))); + assert_eq!(iter.next(), None); + } + + #[test] + fn test_ordered_map_delete() { + let mut map = OrderedMap::new(); + map.insert("key1", "value1"); + map.insert("key2", "value2"); + map.insert("key3", "value3"); + + // Delete a key and check if it is removed. + map.map.remove("key2"); + let mut iter = map.iter(); + assert_eq!(iter.next(), Some((&"key1", &"value1"))); + assert_eq!(iter.next(), Some((&"key3", &"value3"))); + assert_eq!(iter.next(), None); + } + + #[test] + fn test_ordered_map_update() { + let mut map = OrderedMap::new(); + map.insert("key1", "value1"); + map.insert("key2", "value2"); + map.insert("key3", "value3"); + + // Update a value and check if it is updated. + map.insert("key2", "new_value2"); + let mut iter = map.iter(); + assert_eq!(iter.next(), Some((&"key1", &"value1"))); + assert_eq!(iter.next(), Some((&"key2", &"new_value2"))); + assert_eq!(iter.next(), Some((&"key3", &"value3"))); + assert_eq!(iter.next(), None); + } + + #[test] + fn test_ordered_map_get() { + let mut map = OrderedMap::new(); + map.insert("key1", "value1"); + map.insert("key2", "value2"); + map.insert("key3", "value3"); + + assert_eq!(map.get(&"key1"), Some(&"value1")); + assert_eq!(map.get(&"key2"), Some(&"value2")); + assert_eq!(map.get(&"key3"), Some(&"value3")); + assert_eq!(map.get(&"key4"), None); + } + + #[test] + fn test_ordered_map_iter() { + let mut map = OrderedMap::new(); + map.insert("key1", "value1"); + map.insert("key2", "value2"); + map.insert("key3", "value3"); + + let mut iter = map.iter(); + assert_eq!(iter.next(), Some((&"key1", &"value1"))); + assert_eq!(iter.next(), Some((&"key2", &"value2"))); + assert_eq!(iter.next(), Some((&"key3", &"value3"))); + assert_eq!(iter.next(), None); + } +} diff --git a/core/src/logging/color_text_default_styles.rs b/core/src/common/text_default_styles.rs similarity index 100% rename from core/src/logging/color_text_default_styles.rs rename to core/src/common/text_default_styles.rs diff --git a/core/src/lib.rs b/core/src/lib.rs index 4676a211f..f16ac0824 100644 --- a/core/src/lib.rs +++ b/core/src/lib.rs @@ -124,21 +124,17 @@ // Connect to source file. pub mod common; pub mod decl_macros; -pub mod logging; pub mod misc; pub mod storage; pub mod term; pub mod terminal_io; -pub mod tracing_logging; pub mod tui_core; // Re-export. pub use common::*; pub use decl_macros::*; -pub use logging::*; pub use misc::*; pub use storage::*; pub use term::*; pub use terminal_io::*; -pub use tracing_logging::*; pub use tui_core::*; diff --git a/core/src/logging/logging_api.rs b/core/src/logging/logging_api.rs deleted file mode 100644 index cdd962dbb..000000000 --- a/core/src/logging/logging_api.rs +++ /dev/null @@ -1,60 +0,0 @@ -/* - * Copyright (c) 2022 R3BL LLC - * All rights reserved. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -//! This is just a shim (thin wrapper) around the [crate::tracing_logging] module. -//! -//! You can use the functions in this module or just use the [mod@crate::init_tracing] -//! functions directly, along with using [tracing::info!], [tracing::debug!], etc. macros. -//! -//! This file is here as a convenience for backward compatibility w/ the old logging -//! system. - -use crate::{ok, TracingConfig, WriterConfig}; - -const LOG_FILE_NAME: &str = "log.txt"; - -/// Logging is **DISABLED** by **default**. -/// -/// If you don't call this function w/ a value other than -/// [tracing_core::LevelFilter::OFF], then logging won't be enabled. It won't matter if -/// you call any of the other logging functions in this module, or directly use the -/// [tracing::info!], [tracing::debug!], etc. macros. -/// -/// This is a convenience method to setup Tokio [`tracing_subscriber`] with `stdout` as -/// the output destination. This method also ensures that the [`crate::SharedWriter`] is -/// used for concurrent writes to `stdout`. You can also use the [`TracingConfig`] struct -/// to customize the behavior of the tracing setup, by choosing whether to display output -/// to `stdout`, `stderr`, or a [`crate::SharedWriter`]. By default, both display and file -/// logging are enabled. You can also customize the log level, and the file path and -/// prefix for the log file. -pub fn try_initialize_global_logging( - level_filter: tracing_core::LevelFilter, -) -> miette::Result<()> { - // Early return if the level filter is off. - if matches!(level_filter, tracing_core::LevelFilter::OFF) { - return ok!(); - } - - // Try to initialize the tracing system w/ (rolling) file log output. - TracingConfig { - level_filter, - writer_config: WriterConfig::File(LOG_FILE_NAME.to_string()), - } - .install_global()?; - - ok!() -} diff --git a/core/src/logging/simple_file_logging_impl.rs b/core/src/logging/simple_file_logging_impl.rs deleted file mode 100644 index 11dd95e3f..000000000 --- a/core/src/logging/simple_file_logging_impl.rs +++ /dev/null @@ -1,39 +0,0 @@ -/* - * Copyright (c) 2024 R3BL LLC - * All rights reserved. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -use std::{fs::OpenOptions, io::Write, path::Path}; - -/// This is a simple function that logs a message to a file. This is meant to be used when -/// there are no other logging facilities available. -/// -/// # Arguments -/// * `file_path` - The path to the file to log to. If `None`, the default path is `debug.log`. -/// * `message` - The message to log. -pub fn file_log(file_path: Option<&Path>, message: &str) { - let file_path = file_path.unwrap_or(Path::new("debug.log")); - let message = if message.ends_with('\n') { - message.to_string() - } else { - format!("{}\n", message) - }; - let mut file = OpenOptions::new() - .create(true) - .append(true) - .open(file_path) - .unwrap(); - file.write_all(message.as_bytes()).unwrap(); -} diff --git a/core/src/misc/mod.rs b/core/src/misc/mod.rs index beb4899f8..3546c4f2d 100644 --- a/core/src/misc/mod.rs +++ b/core/src/misc/mod.rs @@ -18,7 +18,11 @@ // Attach sources. pub mod calc_str_len; pub mod friendly_random_id; +pub mod string_helpers; +pub mod temp_dir; // Re-export. pub use calc_str_len::*; pub use friendly_random_id::*; +pub use string_helpers::*; +pub use temp_dir::*; diff --git a/core/src/misc/string_helpers.rs b/core/src/misc/string_helpers.rs new file mode 100644 index 000000000..6f2bf66dd --- /dev/null +++ b/core/src/misc/string_helpers.rs @@ -0,0 +1,174 @@ +/* + * Copyright (c) 2024 R3BL LLC + * All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +// 00: [x] test if a string has ansi escape sequences + +use crate::{ch, UnicodeString}; + +/// Tests whether the given text contains an ANSI escape sequence. +pub fn contains_ansi_escape_sequence(text: &str) -> bool { + text.chars().any(|it| it == '\x1b') +} + +#[test] +fn test_contains_ansi_escape_sequence() { + use r3bl_ansi_color::{AnsiStyledText, Color, Style}; + + use crate::assert_eq2; + + assert_eq2!( + contains_ansi_escape_sequence( + "\x1b[31mThis is red text.\x1b[0m And this is normal text." + ), + true + ); + + assert_eq2!(contains_ansi_escape_sequence("This is normal text."), false); + + assert_eq2!( + contains_ansi_escape_sequence( + &AnsiStyledText { + text: "Print a formatted (bold, italic, underline) string w/ ANSI color codes.", + style: &[ + Style::Bold, + Style::Italic, + Style::Underline, + Style::Foreground(Color::Rgb(50, 50, 50)), + Style::Background(Color::Rgb(100, 200, 1)), + ], + } + .to_string() + ), + true + ); +} + +/// Replace escaped quotes with unescaped quotes. The escaped quotes are generated +/// when [std::fmt::Debug] is used to format the output using [format!], eg: +/// ``` +/// use r3bl_core::remove_escaped_quotes; +/// +/// let s = format!("{:?}", "Hello\", world!"); +/// assert_eq!(s, "\"Hello\\\", world!\""); +/// let s = remove_escaped_quotes(&s); +/// assert_eq!(s, "Hello, world!"); +/// ``` +pub fn remove_escaped_quotes(s: &str) -> String { + s.replace("\\\"", "\"").replace("\"", "") +} + +pub mod string_helpers_constants { + pub const SUFFIX: &str = "..."; + pub const SPACER: &str = " "; +} + +/// Take into account the fact that there maybe emoji in the string. +pub fn truncate_from_right(string: &str, display_width: usize, pad: bool) -> String { + use string_helpers_constants::{SPACER, SUFFIX}; + + let display_width = ch!(display_width); + let string = UnicodeString::from(string); + let string_display_width = string.display_width; + + if string_display_width > display_width + // Handle truncation. + { + let suffix = UnicodeString::from(SUFFIX); + let suffix_display_width = suffix.display_width; + + let trunc_string = + string.truncate_end_by_n_col(display_width - suffix_display_width - 1); + // let trunc_string = string.truncate_to_fit_size(crate::size!( + // col_count: display_width - suffix_display_width, + // row_count: 1 + // )); + + format!("{}{}", trunc_string, suffix.string) + } + // Handle padding. + else if pad { + let mut padded_string = string.to_string(); + let display_width_to_pad = ch!(@to_usize display_width - string_display_width); + let display_width_to_pad = display_width_to_pad as f64; + let spacer_display_width = + ch!(@to_usize UnicodeString::from(SPACER).display_width); + let spacer_display_width = spacer_display_width as f64; + let repeat_count = (display_width_to_pad / spacer_display_width).ceil() as usize; + padded_string.push_str(&SPACER.repeat(repeat_count)); + padded_string + } + // No post processing needed. + else { + string.to_string() + } +} + +pub fn truncate_from_left(text: &str, display_width: usize, pad: bool) -> String { + let display_width = ch!(display_width); + let text = UnicodeString::from(text); + let text_width = text.display_width; + + if text_width > display_width { + let suffix = UnicodeString::from(string_helpers_constants::SUFFIX); + let suffix_width = suffix.display_width; + + let truncated_text = + text.truncate_start_by_n_col(display_width - suffix_width - 1); + + format!("{}{}", suffix.string, truncated_text) + } else if pad { + let mut padded_text = text.to_string(); + let width_to_pad = ch!(@to_usize display_width - text_width); + let spacer_width = ch!(@to_usize UnicodeString::from(string_helpers_constants::SPACER).display_width); + let repeat_count = (width_to_pad as f64 / spacer_width as f64).ceil() as usize; + padded_text.insert_str(0, &string_helpers_constants::SPACER.repeat(repeat_count)); + padded_text + } else { + text.to_string() + } +} + +#[cfg(test)] +mod tests_truncate_or_pad { + use super::*; + + #[test] + fn test_truncate_or_pad_from_right() { + let long_string = "Hello, world!"; + let short_string = "Hi!"; + let width = 10; + + assert_eq!(truncate_from_right(long_string, width, true), "Hello, ..."); + assert_eq!(truncate_from_right(short_string, width, true), "Hi! "); + + assert_eq!(truncate_from_right(long_string, width, false), "Hello, ..."); + assert_eq!(truncate_from_right(short_string, width, false), "Hi!"); + } + + #[test] + fn test_truncate_or_pad_from_left() { + let long_string = "Hello, world!"; + let short_string = "Hi!"; + let width = 10; + + assert_eq!(truncate_from_left(long_string, width, true), "... world!"); + assert_eq!(truncate_from_left(short_string, width, true), " Hi!"); + + assert_eq!(truncate_from_left(long_string, width, false), "... world!"); + assert_eq!(truncate_from_left(short_string, width, false), "Hi!"); + } +} diff --git a/test_fixtures/src/temp_dir.rs b/core/src/misc/temp_dir.rs similarity index 79% rename from test_fixtures/src/temp_dir.rs rename to core/src/misc/temp_dir.rs index fd0418690..5c62eb1ab 100644 --- a/test_fixtures/src/temp_dir.rs +++ b/core/src/misc/temp_dir.rs @@ -20,12 +20,20 @@ use std::{fmt::{Display, Formatter}, path::Path}; use miette::IntoDiagnostic; -use r3bl_core::friendly_random_id; + +use crate::friendly_random_id; pub struct TempDir { inner: std::path::PathBuf, } +impl TempDir { + /// Join a path to the temporary directory. + pub fn join>(&self, path: P) -> std::path::PathBuf { + self.inner.join(path) + } +} + /// Create a temporary directory. The directory is automatically deleted when the /// [TempDir] struct is dropped. pub fn create_temp_dir() -> miette::Result { @@ -51,7 +59,7 @@ impl Drop for TempDir { /// # Example /// /// ```no_run -/// use r3bl_test_fixtures::create_temp_dir; +/// use r3bl_core::create_temp_dir; /// let root = create_temp_dir().unwrap(); /// let new_dir = root.join("test_set_file_executable"); /// ``` @@ -68,7 +76,7 @@ impl Deref for TempDir { /// # Example /// /// ```no_run -/// use r3bl_test_fixtures::create_temp_dir; +/// use r3bl_core::create_temp_dir; /// let root = create_temp_dir().unwrap(); /// println!("Temp dir: {}", root); /// ``` @@ -88,7 +96,7 @@ impl Display for TempDir { /// # Example /// /// ```no_run -/// use r3bl_test_fixtures::create_temp_dir; +/// use r3bl_core::create_temp_dir; /// let root = create_temp_dir().unwrap(); /// std::fs::create_dir_all(root.join("test_set_file_executable")).unwrap(); /// std::fs::remove_dir_all(root).unwrap(); @@ -98,7 +106,7 @@ impl AsRef for TempDir { } #[cfg(test)] -mod tests { +mod tests_temp_dir { use crossterm::style::Stylize as _; use super::*; @@ -114,6 +122,22 @@ mod tests { assert!(temp_dir.inner.exists()); } + #[test] + fn test_temp_dir_join() { + let temp_dir = create_temp_dir().unwrap(); + let expected_prefix = temp_dir.inner.display().to_string(); + + let new_sub_dir = temp_dir.join("test_set_file_executable"); + let expected_postfix = new_sub_dir.display().to_string(); + + let expected_full_path = new_sub_dir.display().to_string(); + + assert!(temp_dir.exists()); + assert!(!new_sub_dir.exists()); + assert!(expected_full_path.starts_with(&expected_prefix)); + assert!(expected_full_path.ends_with(&expected_postfix)); + } + #[test] fn test_temp_dir_drop() { let temp_dir = create_temp_dir().unwrap(); diff --git a/core/src/storage/kv.rs b/core/src/storage/kv.rs index 3aa8206bc..e5e46ed08 100644 --- a/core/src/storage/kv.rs +++ b/core/src/storage/kv.rs @@ -353,19 +353,16 @@ use kv_error::*; #[cfg(test)] mod kv_tests { - use std::{collections::HashMap, - path::{Path, PathBuf}}; + use std::{collections::HashMap, path::Path}; use serial_test::serial; - use tempfile::tempdir; use tracing::{instrument, Level}; use super::*; + use crate::create_temp_dir; fn check_folder_exists(path: &Path) -> bool { path.exists() && path.is_dir() } - fn join_path_with_str(path: &Path, str: &str) -> PathBuf { path.join(str) } - fn setup_tracing() { let _ = tracing_subscriber::fmt() .with_max_level(Level::INFO) @@ -378,21 +375,13 @@ mod kv_tests { .try_init(); } - fn get_path(dir: &tempfile::TempDir, folder_name: &str) -> PathBuf { - join_path_with_str(dir.path(), folder_name) - } - - fn create_temp_folder() -> tempfile::TempDir { - tempdir().expect("Failed to create temp dir") - } - #[instrument] fn perform_db_operations() -> miette::Result<()> { let bucket_name = "bucket".to_string(); - // Setup temp folder. - let dir = create_temp_folder(); - let path_buf = get_path(&dir, "db_folder"); + // Setup temp dir (this will be dropped when `dir` is out of scope). + let root_temp_dir = create_temp_dir()?; + let path_buf = root_temp_dir.join("db_folder"); setup_tracing(); @@ -470,9 +459,9 @@ mod kv_tests { fn perform_db_operations_error_conditions() -> miette::Result<()> { let bucket_name = "bucket".to_string(); - // Setup temp folder. - let dir = create_temp_folder(); - let path_buf = get_path(&dir, "db_folder"); + // Setup temp dir (this will be dropped when `dir` is out of scope). + let root_temp_dir = create_temp_dir()?; + let path_buf = root_temp_dir.join("db_folder"); setup_tracing(); diff --git a/core/src/tui_core/graphemes/access.rs b/core/src/tui_core/graphemes/access.rs index 41359cceb..abb572074 100644 --- a/core/src/tui_core/graphemes/access.rs +++ b/core/src/tui_core/graphemes/access.rs @@ -52,11 +52,22 @@ impl UnicodeString { display_width } + /// The `size` is a column index and row index. Not width or height. + /// - To convert width -> size / column index subtract 1. + /// - To convert size / column index to width add 1. + /// + /// Note the [Self::truncate_end_by_n_col] and [Self::truncate_start_by_n_col] + /// functions take a width. pub fn truncate_to_fit_size(&self, size: Size) -> &str { let display_cols: ChUnit = size.col_count; self.truncate_end_to_fit_width(display_cols) } + /// The `n_display_col` is a width, not a [Size]. + /// - To convert width -> size / column index subtract 1. + /// - To convert size / column index to width add 1. + /// + /// Note the [Self::truncate_to_fit_size] function takes a size / column index. pub fn truncate_end_by_n_col(&self, n_display_col: ChUnit) -> &str { let mut countdown_col_count = n_display_col; let mut string_end_byte_index = 0; diff --git a/core/src/tui_core/graphemes/convert.rs b/core/src/tui_core/graphemes/convert.rs index 490fa7042..dd9769c7c 100644 --- a/core/src/tui_core/graphemes/convert.rs +++ b/core/src/tui_core/graphemes/convert.rs @@ -19,7 +19,7 @@ use std::borrow::Cow; use crate::UnicodeString; -// Convert to UnicodeString +/// Convert to UnicodeString. impl From<&str> for UnicodeString { fn from(s: &str) -> Self { UnicodeString::new(s) } } @@ -40,12 +40,12 @@ impl From<&String> for UnicodeString { fn from(s: &String) -> Self { UnicodeString::new(s) } } -// Convert to String +/// Convert to String. impl From for String { fn from(s: UnicodeString) -> Self { s.string } } -// UnicodeStringExt +/// UnicodeStringExt trait. pub trait UnicodeStringExt { fn unicode_string(&self) -> UnicodeString; } @@ -61,3 +61,10 @@ impl UnicodeStringExt for &str { impl UnicodeStringExt for String { fn unicode_string(&self) -> UnicodeString { UnicodeString::from(self) } } + +/// Implement Display trait. +impl std::fmt::Display for UnicodeString { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}", self.string) + } +} \ No newline at end of file diff --git a/log/Cargo.toml b/log/Cargo.toml new file mode 100644 index 000000000..2795321be --- /dev/null +++ b/log/Cargo.toml @@ -0,0 +1,60 @@ +[package] +name = "r3bl_log" +version = "0.1.0" +edition = "2024" +resolver = "2" +description = "Tokio tracing plugins for formatted log output for R3BL TUI crates" +# At most 5 keywords w/ no spaces, each has max length of 20 char. +keywords = ["log", "tracing", "ANSI", "terminal", "formatted"] +categories = ["command-line-interface", "command-line-utilities"] +readme = "README.md" # This is not included in cargo docs. +# Email address(es) has to be verified at https://crates.io/me/ +authors = [ + "Nazmul Idris ", + "Nadia Idris ", +] +repository = "https://github.com/r3bl-org/r3bl-open-core/tree/main/log" +documentation = "https://docs.rs/r3bl_log" +homepage = "https://r3bl.com" +license = "Apache-2.0" + +[dependencies] +# Tokio / Tracing / Logging. +# https://tokio.rs/tokio/topics/tracing +# https://tokio.rs/tokio/topics/tracing-next-steps +tokio = { version = "1.40.0", features = ["full", "tracing"] } +tracing = "0.1.40" +tracing-subscriber = "0.3.18" +tracing-appender = "0.2.3" +tracing-core = "0.1.32" + +# CustomEventFormatter. +chrono = "0.4.39" +textwrap = { version = "0.16.1", features = ["unicode-linebreak"] } + +# Error handling. +thiserror = "1.0.64" +miette = { version = "7.2.0", features = ["fancy"] } +pretty_assertions = "1.4.1" + +# r3bl-open-core. +r3bl_ansi_color = { path = "../ansi_color", version = "0.7.0" } # Convert between ansi and rgb. +r3bl_core = { path = "../core", version = "0.10.0" } # Core functionality. +r3bl_macro = { path = "../macro", version = "0.10.0" } # Macros for r3bl-open-core. +r3bl_test_fixtures = { path = "../test_fixtures", version = "0.1.0" } # Test fixtures. + +# Terminal color output. +crossterm = "0.28.1" + +[dev-dependencies] + +# for assert_eq! macro +pretty_assertions = "1.4.1" +serial_test = "3.1.1" + +# Bin targets for testing stdout and stderr. +assert_cmd = "2.0.16" + +[[bin]] +name = "tracing_test_bin" +path = "src/bin/tracing_test_bin.rs" diff --git a/core/src/bin/tracing_test_bin.rs b/log/src/bin/tracing_test_bin.rs similarity index 69% rename from core/src/bin/tracing_test_bin.rs rename to log/src/bin/tracing_test_bin.rs index 12a0233a6..907d68360 100644 --- a/core/src/bin/tracing_test_bin.rs +++ b/log/src/bin/tracing_test_bin.rs @@ -15,16 +15,26 @@ * limitations under the License. */ -use r3bl_core::{DisplayPreference, TracingConfig, WriterConfig}; +use r3bl_log::{DisplayPreference, TracingConfig, WriterConfig}; use tracing_core::LevelFilter; -/// `assert_cmd` : +/// This test works with the binary under test, which is `tracing_stdout_test_bin`. That +/// binary takes 1 string argument: "stdout" or "stderr". It uses the `assert_cmd` crate +/// to verify that the [DisplayPreference::Stdout] and [DisplayPreference::Stderr] work as +/// expected. There is no easy way to actually test `stdout` and `stderr` without spawning +/// a new process, so this is the best way to test it. +/// +/// +/// This is the binary under test, which is tested by the `test_tracing_bin_stdio` test +/// module. /// -/// This is the binary under test, which is tested by the `test_tracing_stdout` test. /// It takes 1 argument: "stdout" or "stderr". Depending on the argument, it will /// display the logs to stdout or stderr. /// -/// See: `init_tracing.rs` and `test_tracing_bin_stdio()` test. +/// See: +/// 1. Test module: `test_tracing_bin_stdio` +/// 2. Binary under test: `tracing_test_bin.rs` <- you are here. +/// 3. `assert_cmd` : fn main() { // Get the argument passed to the binary. let arg = std::env::args().nth(1).unwrap_or_default(); diff --git a/core/src/logging/mod.rs b/log/src/lib.rs similarity index 72% rename from core/src/logging/mod.rs rename to log/src/lib.rs index 1ce0c7f0b..4afa5851f 100644 --- a/core/src/logging/mod.rs +++ b/log/src/lib.rs @@ -1,5 +1,5 @@ /* - * Copyright (c) 2022 R3BL LLC + * Copyright (c) 2024 R3BL LLC * All rights reserved. * * Licensed under the Apache License, Version 2.0 (the "License"); @@ -15,12 +15,8 @@ * limitations under the License. */ -// Attach. -pub mod color_text_default_styles; -pub mod logging_api; -pub mod simple_file_logging_impl; +// Attach sources. +pub mod log_support; // Re-export. -pub use color_text_default_styles::*; -pub use logging_api::*; -pub use simple_file_logging_impl::*; +pub use log_support::*; diff --git a/log/src/log_support/custom_event_formatter.rs b/log/src/log_support/custom_event_formatter.rs new file mode 100644 index 000000000..de56c9826 --- /dev/null +++ b/log/src/log_support/custom_event_formatter.rs @@ -0,0 +1,254 @@ +/* + * Copyright (c) 2024 R3BL LLC + * All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +use std::fmt; + +use chrono::Local; +use crossterm::style::Stylize; +use custom_event_formatter_constants::{DEBUG_SIGIL, + ERROR_SIGIL, + FIRST_LINE_PREFIX, + INFO_SIGIL, + LEVEL_SUFFIX, + SUBSEQUENT_LINE_PREFIX, + TRACE_SIGIL, + WARN_SIGIL}; +use r3bl_core::{ColorWheel, + OrderedMap, + UnicodeString, + ch, + get_terminal_width, + remove_escaped_quotes, + string_helpers_constants, + truncate_from_right}; +use r3bl_macro::tui_style; +use textwrap::{Options, WordSeparator, wrap}; +use tracing::{Event, + Subscriber, + field::{Field, Visit}}; +use tracing_subscriber::{fmt::{FormatEvent, FormatFields}, + registry::LookupSpan}; + +pub struct CustomEventFormatter; + +pub mod custom_event_formatter_constants { + pub const FIRST_LINE_PREFIX: &str = "𜱐 "; + pub const SUBSEQUENT_LINE_PREFIX: &str = " "; + pub const LEVEL_SUFFIX: &str = ":"; + pub const ERROR_SIGIL: &str = "E"; + pub const WARN_SIGIL: &str = "W"; + pub const INFO_SIGIL: &str = "I"; + pub const DEBUG_SIGIL: &str = "D"; + pub const TRACE_SIGIL: &str = "T"; +} + +impl FormatEvent for CustomEventFormatter +where + S: Subscriber + for<'a> LookupSpan<'a>, + N: for<'a> FormatFields<'a> + 'static, +{ + /// Format the event into 2 lines: + /// 1. Timestamp, span context, level, and message truncated to the available visible + /// width. + /// 2. Body that is text wrapped to the visible width. + /// + /// This function takes into account text that can contain emoji. + fn format_event( + &self, + ctx: &tracing_subscriber::fmt::FmtContext<'_, S, N>, + mut writer: tracing_subscriber::fmt::format::Writer<'_>, + event: &Event<'_>, + ) -> fmt::Result { + // Get spacer. + let spacer = string_helpers_constants::SPACER; + let spacer_display_width = + ch!(@to_usize UnicodeString::from(spacer).display_width); + + // Length accumulator (for line width calculations). + let mut line_width_used = 0; + + // Custom timestamp. + let timestamp = Local::now(); + let timestamp_str = + format!("{ts}{sp}", ts = timestamp.format("%I:%M%P"), sp = spacer); + line_width_used += timestamp_str.len(); + let timestamp_str_fmt = timestamp_str.grey().on_dark_grey().underlined(); + write!(writer, "\n{timestamp_str_fmt}")?; + + // Custom span context. + if let Some(scope) = ctx.lookup_current() { + let scope_str = format!("[{}] ", scope.name()); + line_width_used += scope_str.len(); + let scope_str_fmt = scope_str.grey().on_dark_grey().italic().underlined(); + write!(writer, "{scope_str_fmt}")?; + } + + // Custom metadata formatting. For eg: + // + // metadata: Metadata { + // name: "event src/bin/gen-certs.rs:110", + // target: "gen_certs", + // level: Level( + // Debug, + // ), + // module_path: "gen_certs", + // location: src/bin/gen-certs.rs:110, + // fields: {msg, body}, + // callsite: Identifier(0x5a46fb928d40), + // kind: Kind(EVENT), + // } + let (level_str, level_str_fmt) = match *event.metadata().level() { + tracing::Level::ERROR => { + let text = format!("{ERROR_SIGIL}{LEVEL_SUFFIX}{spacer}"); + let text_fmt = text.clone().red(); + (text, text_fmt) + } + tracing::Level::WARN => { + let text = format!("{WARN_SIGIL}{LEVEL_SUFFIX}{spacer}"); + let text_fmt = text.clone().magenta(); + (text, text_fmt) + } + tracing::Level::INFO => { + let text = format!("{INFO_SIGIL}{LEVEL_SUFFIX}{spacer}"); + let text_fmt = text.clone().blue(); + (text, text_fmt) + } + tracing::Level::DEBUG => { + let text = format!("{DEBUG_SIGIL}{LEVEL_SUFFIX}{spacer}"); + let text_fmt = text.clone().yellow(); + (text, text_fmt) + } + tracing::Level::TRACE => { + let text = format!("{TRACE_SIGIL}{LEVEL_SUFFIX}{spacer}"); + let text_fmt = text.clone().grey(); + (text, text_fmt) + } + }; + let level_str_fmt = level_str_fmt.on_dark_grey().bold().underlined(); + let level_str_display_width = + ch!(@to_usize UnicodeString::from(&level_str).display_width); + line_width_used += spacer_display_width; + line_width_used += level_str_display_width; + write!(writer, "{level_str_fmt}")?; + + // Custom field formatting. For eg: + // + // fields: ValueSet { + // msg: "pwd at end", + // body: "Ok(\"/home/nazmul/github/rust-scratch/tls\")", + // callsite: Identifier(0x5a46fb928d40), + // } + // + // Instead of: + // ctx.field_format().format_fields(writer.by_ref(), event)?; + let mut ordered_map = OrderedMap::::default(); + event.record(&mut VisitEventAndPopulateOrderedMapWithFields { + inner: &mut ordered_map, + }); + + let max_display_width = get_terminal_width(); + + let text_wrap_options = Options::new(max_display_width) + .initial_indent(FIRST_LINE_PREFIX) + .subsequent_indent(SUBSEQUENT_LINE_PREFIX) + .word_separator(WordSeparator::UnicodeBreakProperties); + + for (msg, body) in ordered_map.iter() { + // Prepare the msg and body. + let msg = remove_escaped_quotes(msg); + let body = remove_escaped_quotes(body); + + // Write msg line. + line_width_used += 1; + let line_1_width = max_display_width - line_width_used; + let msg = format!(" {}\n", truncate_from_right(&msg, line_1_width, false)); + let msg_fmt = ColorWheel::lolcat_into_string( + &msg, + Some(tui_style!( + attrib: [bold, italic, underline] + )), + ); + write!(writer, "{msg_fmt}")?; + + // Write body line(s). + let body = wrap(&body, &text_wrap_options); + for body_line in body.iter() { + let body_line = truncate_from_right(body_line, max_display_width, true); + let body_line_fmt = body_line.to_string().dark_grey(); + writeln!(writer, "{body_line_fmt}")?; + } + } + + // Write the terminating line separator. + let line_separator = "‾".repeat(max_display_width); + let line_separator_fmt = line_separator.dark_green(); + writeln!(writer, "{line_separator_fmt}") + } +} + +pub struct VisitEventAndPopulateOrderedMapWithFields<'a> { + inner: &'a mut OrderedMap, +} + +impl Visit for VisitEventAndPopulateOrderedMapWithFields<'_> { + fn record_debug(&mut self, field: &Field, value: &dyn fmt::Debug) { + let field_name = field.name(); + let field_value = format!("{:?}", value); + self.inner.insert(field_name.to_string(), field_value); + } +} + +#[cfg(test)] +mod tests_tracing_custom_event_formatter { + use std::sync::Mutex; + + use chrono::Local; + use r3bl_test_fixtures::StdoutMock; + use tracing::{info, subscriber::set_global_default}; + use tracing_subscriber::fmt::SubscriberBuilder; + + use super::*; + + #[test] + fn test_custom_formatter() { + let mock_stdout = StdoutMock::new(); + let mock_stdout_clone = mock_stdout.clone(); + let subscriber = SubscriberBuilder::default() + .event_format(CustomEventFormatter) + .with_writer(Mutex::new(mock_stdout)) + .finish(); + + set_global_default(subscriber).expect("Failed to set subscriber"); + + info!(message = "This is a test log entry"); + + let time = Local::now().format("%I:%M%P").to_string(); + let it = mock_stdout_clone.get_copy_of_buffer_as_string(); + let it_no_ansi = mock_stdout_clone.get_copy_of_buffer_as_string_strip_ansi(); + + // println!("{}", it); + // println!("{}", it_no_ansi); + + assert!(it_no_ansi.contains("message")); // lolcat colorized each char, so strip the colors. + assert!(it.matches("░").count() >= 2); + assert!(it.matches("‾").count() >= 1); + assert!(it.contains("This is a test log entry")); + assert!(it.contains("I:")); + assert!(it.contains(&time)); + assert!(it.matches('\n').count() >= 4); // There are many new lines. + } +} diff --git a/core/src/tracing_logging/mod.rs b/log/src/log_support/mod.rs similarity index 83% rename from core/src/tracing_logging/mod.rs rename to log/src/log_support/mod.rs index 91d5390f1..2c2f533e6 100644 --- a/core/src/tracing_logging/mod.rs +++ b/log/src/log_support/mod.rs @@ -16,11 +16,15 @@ */ // Attach sources. -pub mod init_tracing; +pub mod custom_event_formatter; +pub mod public_api; pub mod rolling_file_appender_impl; pub mod tracing_config; +pub mod tracing_init; // Re-export. -pub use init_tracing::*; +pub use custom_event_formatter::*; +pub use public_api::*; pub use rolling_file_appender_impl::*; pub use tracing_config::*; +pub use tracing_init::*; diff --git a/log/src/log_support/public_api.rs b/log/src/log_support/public_api.rs new file mode 100644 index 000000000..df13d7ef1 --- /dev/null +++ b/log/src/log_support/public_api.rs @@ -0,0 +1,127 @@ +/* + * Copyright (c) 2022 R3BL LLC + * All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +use std::{fs::OpenOptions, io::Write, path::Path}; + +use r3bl_core::ok; +use tracing::dispatcher; + +use crate::{TracingConfig, WriterConfig}; + +const LOG_FILE_NAME: &str = "log.txt"; + +/// Global default subscriber, which once set, can't be unset or changed. +/// - This is great for apps. +/// - Docs for [Global default tracing +/// subscriber](https://docs.rs/tracing/latest/tracing/subscriber/fn.set_global_default.html) +/// +/// Logging is **DISABLED** by **default**. +/// +/// If you don't call this function w/ a value other than +/// [tracing_core::LevelFilter::OFF], then logging won't be enabled. It won't matter if +/// you call any of the other logging functions in this module, or directly use the +/// [tracing::info!], [tracing::debug!], etc. macros. +/// +/// This is a convenience method to setup Tokio [`tracing_subscriber`] with `stdout` as +/// the output destination. This method also ensures that the [`r3bl_core::SharedWriter`] +/// is used for concurrent writes to `stdout`. You can also use the [`TracingConfig`] +/// struct to customize the behavior of the tracing setup, by choosing whether to display +/// output to `stdout`, `stderr`, or a [`r3bl_core::SharedWriter`]. By default, both +/// display and file logging are enabled. You can also customize the log level, and the +/// file path and prefix for the log file. +/// +/// You can use the functions in this module or just use the [mod@crate::log_support] +/// functions directly, along with using [tracing::info!], [tracing::debug!], etc. macros. +/// +/// If you don't want to use sophisticated logging, you can use the [file_log] function to +/// log messages to a file. +pub fn try_initialize_logging_global( + level_filter: tracing_core::LevelFilter, +) -> miette::Result<()> { + // Early return if the level filter is off. + if matches!(level_filter, tracing_core::LevelFilter::OFF) { + return ok!(); + } + + // Try to initialize the tracing system w/ (rolling) file log output. + TracingConfig { + level_filter, + writer_config: WriterConfig::File(LOG_FILE_NAME.to_string()), + } + .install_global()?; + + ok!() +} + +/// Thread local subscriber, which is thread local, and you can assign different ones +/// to different threads. +/// - This is great for tests. +/// - Docs for [Thread local tracing +/// subscriber](https://docs.rs/tracing/latest/tracing/subscriber/fn.set_default.html) +/// +/// Logging is **DISABLED** by **default**. +/// +/// If you don't call this function w/ a value other than +/// [tracing_core::LevelFilter::OFF], then logging won't be enabled. It won't matter if +/// you call any of the other logging functions in this module, or directly use the +/// [tracing::info!], [tracing::debug!], etc. macros. +/// +/// Unlike [try_initialize_logging_global], this function initializes the logging system +/// per thread. This is useful when you want to have different log levels for different +/// threads, eg in different tests. +/// +/// If you don't want to use sophisticated logging, you can use the [file_log] function to +/// log messages to a file. +pub fn try_initialize_logging_thread_local( + level_filter: tracing_core::LevelFilter, +) -> miette::Result> { + // Early return if the level filter is off. + if matches!(level_filter, tracing_core::LevelFilter::OFF) { + return Ok(None); + } + + // Try to initialize the tracing system w/ (rolling) file log output. + TracingConfig { + level_filter, + writer_config: WriterConfig::File(LOG_FILE_NAME.to_string()), + } + .install_thread_local() + .map(Some) +} + +/// This is a simple function that logs a message to a file. This is meant to be used when +/// there are no other logging facilities available, such as +/// [try_initialize_logging_global] or [try_initialize_logging_thread_local]. +/// +/// # Arguments +/// * `file_path` - The path to the file to log to. If `None`, the default path is +/// `debug.log`. +/// * `message` - The message to log. +pub fn file_log(file_path: Option<&Path>, message: &str) { + let file_path = file_path.unwrap_or(Path::new("debug.log")); + let message = if message.ends_with('\n') { + message.to_string() + } else { + format!("{}\n", message) + }; + let mut file = OpenOptions::new() + .create(true) + .append(true) + .open(file_path) + .unwrap(); + file.write_all(message.as_bytes()).unwrap(); +} diff --git a/core/src/tracing_logging/rolling_file_appender_impl.rs b/log/src/log_support/rolling_file_appender_impl.rs similarity index 100% rename from core/src/tracing_logging/rolling_file_appender_impl.rs rename to log/src/log_support/rolling_file_appender_impl.rs diff --git a/core/src/tracing_logging/tracing_config.rs b/log/src/log_support/tracing_config.rs similarity index 99% rename from core/src/tracing_logging/tracing_config.rs rename to log/src/log_support/tracing_config.rs index d2e36d71d..f78b0f911 100644 --- a/core/src/tracing_logging/tracing_config.rs +++ b/log/src/log_support/tracing_config.rs @@ -17,12 +17,12 @@ use std::fmt::Debug; +use r3bl_core::SharedWriter; use tracing::dispatcher; use tracing_core::LevelFilter; use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt}; use super::try_create_layers; -use crate::SharedWriter; /// - `tracing_log_file_path_and_prefix`: [String] is the file path and prefix to use for /// the log file. Eg: `/tmp/tcp_api_server` or `tcp_api_server`. diff --git a/core/src/tracing_logging/init_tracing.rs b/log/src/log_support/tracing_init.rs similarity index 87% rename from core/src/tracing_logging/init_tracing.rs rename to log/src/log_support/tracing_init.rs index 34c4f1683..0d29ac449 100644 --- a/core/src/tracing_logging/init_tracing.rs +++ b/log/src/log_support/tracing_init.rs @@ -16,10 +16,10 @@ */ use tracing_core::LevelFilter; -use tracing_subscriber::{registry::LookupSpan, Layer}; +use tracing_subscriber::{Layer, registry::LookupSpan}; use super::{DisplayPreference, WriterConfig}; -use crate::tracing_logging::{rolling_file_appender_impl, tracing_config::TracingConfig}; +use crate::log_support::{rolling_file_appender_impl, tracing_config::TracingConfig}; /// Avoid gnarly type annotations by using a macro to create the `fmt` layer. Note that /// [tracing_subscriber::fmt::format::Pretty] and @@ -27,15 +27,16 @@ use crate::tracing_logging::{rolling_file_appender_impl, tracing_config::Tracing #[macro_export] macro_rules! create_fmt { () => { - tracing_subscriber::fmt::layer() - .compact() - .without_time() - .with_thread_ids(false) - .with_thread_names(false) - .with_target(false) - .with_file(false) - .with_line_number(false) - .with_ansi(true) + // 00: [ ] replace the default stuff w/ the custom stuff above + tracing_subscriber::fmt::layer().event_format($crate::CustomEventFormatter) + // .compact() + // .without_time() + // .with_thread_ids(false) + // .with_thread_names(false) + // .with_target(false) + // .with_file(false) + // .with_line_number(false) + // .with_ansi(true) }; } @@ -162,7 +163,7 @@ where #[cfg(test)] mod tests { - use tempfile::tempdir; + use r3bl_core::create_temp_dir; use super::*; @@ -178,8 +179,8 @@ mod tests { #[test] fn test_try_create_file_layer() { - let dir = tempdir().unwrap(); - let file_path = dir.path().join("my_temp_log_file.log"); + let dir = create_temp_dir().unwrap(); + let file_path = dir.join("my_temp_log_file.log"); let file_path = file_path.to_str().unwrap().to_string(); println!("file_path: {}", file_path); @@ -195,8 +196,8 @@ mod tests { #[test] fn test_try_create_both_layers() { - let dir = tempdir().unwrap(); - let file_path = dir.path().join("my_temp_log_file.log"); + let dir = create_temp_dir().unwrap(); + let file_path = dir.join("my_temp_log_file.log"); let file_path = file_path.to_str().unwrap().to_string(); let tracing_config = TracingConfig { @@ -214,13 +215,19 @@ mod tests { } /// This test works with the binary under test, which is `tracing_stdout_test_bin`. That -/// binary takes 1 string argument: "stdout" or "stderr". +/// binary takes 1 string argument: "stdout" or "stderr". It uses the `assert_cmd` crate +/// to verify that the [DisplayPreference::Stdout] and [DisplayPreference::Stderr] work as +/// expected. There is no easy way to actually test `stdout` and `stderr` without spawning +/// a new process, so this is the best way to test it. /// /// If tests in this module fail, then make sure that the binary under test has been, in /// fact, built. So, make sure to run `cargo build && cargo test` rather than just `cargo /// test`.` /// -/// See: `tracing_stdout_test_bin.rs` +/// See: +/// 1. Test module: `test_tracing_bin_stdio` +/// 2. Binary under test: `tracing_test_bin.rs` <- you are here. +/// 3. `assert_cmd` : #[cfg(test)] mod test_tracing_bin_stdio { use assert_cmd::Command; @@ -258,8 +265,9 @@ mod test_tracing_bin_stdio { #[cfg(test)] mod test_tracing_shared_writer_output { + use r3bl_core::{LineStateControlSignal, SharedWriter}; + use super::*; - use crate::{LineStateControlSignal, SharedWriter}; const EXPECTED: [&str; 4] = ["error", "warn", "info", "debug"]; diff --git a/script/Cargo.toml b/script/Cargo.toml new file mode 100644 index 000000000..86067c8c2 --- /dev/null +++ b/script/Cargo.toml @@ -0,0 +1,54 @@ +[package] +name = "r3bl_script" +version = "0.1.0" +edition = "2024" +resolver = "2" +description = "Rust support for scripting, with logging, tracing, and ANSI color output" +# At most 5 keywords w/ no spaces, each has max length of 20 char. +keywords = ["scripting", "command", "ANSI", "terminal", "formatted"] +categories = ["command-line-interface", "command-line-utilities"] +readme = "README.md" # This is not included in cargo docs. +# Email address(es) has to be verified at https://crates.io/me/ +authors = [ + "Nazmul Idris ", + "Nadia Idris ", +] +repository = "https://github.com/r3bl-org/r3bl-open-core/tree/main/script" +documentation = "https://docs.rs/r3bl_script" +homepage = "https://r3bl.com" +license = "Apache-2.0" + +[dependencies] +# R3BL awesomeness. +futures-util = "0.3.31" # Async streams. +r3bl_ansi_color = { path = "../ansi_color", version = "0.7.0" } +r3bl_core = { path = "../core", version = "0.10.0" } +r3bl_macro = { path = "../macro", version = "0.10.0" } # TUI style macro. + +# Tokio dependencies. +tokio = { version = "1.42.0", features = ["full"] } + +# Tokio tracing dependencies. +tracing = "0.1.41" +tracing-subscriber = "0.3.19" +tracing-core = "0.1.33" +chrono = "0.4.39" +textwrap = "0.16.1" + +# Error handling. +miette = "7.4.0" +thiserror = "2.0.6" + +# Terminal color output. +crossterm = "0.28.1" + +# Strum dependencies. +strum = "0.26.3" +strum_macros = "0.26.4" + +# Run tests in serial. +serial_test = "3.2.0" + +# HTTP client library. +reqwest = { version = "0.12.9", features = ["json"] } +serde_json = "1.0.133" diff --git a/script/src/apt_install.rs b/script/src/apt_install.rs new file mode 100644 index 000000000..f26853fdd --- /dev/null +++ b/script/src/apt_install.rs @@ -0,0 +1,111 @@ +/* + * Copyright (c) 2024 R3BL LLC + * All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +use miette::IntoDiagnostic; +use r3bl_core::ok; + +use crate::command; + +/// Here are some examples of using `dpkg-query` to check if a package is installed: +/// +/// ```fish +/// set package_name "openssl" +/// dpkg-query -s $package_name +/// echo $status +/// if test $status -eq 0 +/// echo "True if package is installed" +/// else +/// echo "False if package is not installed" +/// end +/// ``` +/// +/// # Example +/// +/// ```no_run +/// use r3bl_script::apt_install::check_if_package_is_installed; +/// +/// async fn check() { +/// let package_name = "bash"; +/// let is_installed = check_if_package_is_installed(package_name).await.unwrap(); +/// assert!(is_installed); +/// } +/// ``` +/// +/// ```no_run +/// use r3bl_script::apt_install::install_package; +/// +/// async fn install() { +/// let package_name = "does_not_exist"; +/// assert!(install_package(package_name).await.is_err()); +/// } +/// ``` +pub async fn check_if_package_is_installed(package_name: &str) -> miette::Result { + let output = command!( + program => "dpkg-query", + args => "-s", package_name + ) + .output() + .await + .into_diagnostic()?; + ok!(output.status.success()) +} + +pub async fn install_package(package_name: &str) -> miette::Result<()> { + let command = command!( + program => "sudo", + args => "apt", "install", "-y", package_name + ) + .output() + .await + .into_diagnostic()?; + if command.status.success() { + ok!() + } else { + miette::bail!( + "Failed to install package: {:?} with sudo apt", + String::from_utf8_lossy(&command.stderr) + ); + } +} + +#[cfg(test)] +mod tests_apt_install { + use r3bl_ansi_color::{TTYResult, is_fully_uninteractive_terminal}; + + use super::*; + + #[tokio::test] + async fn test_check_if_package_is_installed() { + // This is for CI/CD. + if let TTYResult::IsNotInteractive = is_fully_uninteractive_terminal() { + return; + } + let package_name = "bash"; + let is_installed = check_if_package_is_installed(package_name).await.unwrap(); + assert!(is_installed); + } + + #[tokio::test] + async fn test_install_package() { + // This is for CI/CD. + if let TTYResult::IsNotInteractive = is_fully_uninteractive_terminal() { + return; + } + let package_name = "does_not_exist"; + assert!(install_package(package_name).await.is_err()); + } +} diff --git a/script/src/command_runner.rs b/script/src/command_runner.rs new file mode 100644 index 000000000..f7125c54e --- /dev/null +++ b/script/src/command_runner.rs @@ -0,0 +1,367 @@ +/* + * Copyright (c) 2024 R3BL LLC + * All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +use std::process::Stdio; + +use miette::{Context, IntoDiagnostic}; +use r3bl_core::ok; +use tokio::{io::AsyncWriteExt as _, + process::{Child, Command}}; + +/// This macro to create a [std::process::Command] that receives a set of arguments and +/// returns it. +/// +/// # Example of command and args +/// +/// ``` +/// use r3bl_script::command; +/// use std::process::Command; +/// +/// async fn run_command() { +/// let mut command = command!( +/// program => "echo", +/// args => "Hello, world!", +/// ); +/// let output = command.output().await.expect("Failed to execute command"); +/// assert!(output.status.success()); +/// assert_eq!(String::from_utf8_lossy(&output.stdout), "Hello, world!\n"); +/// } +/// ``` +/// +/// # Example of command, env, and args +/// +/// ``` +/// use r3bl_script::command; +/// use r3bl_script::environment::{self, EnvKeys}; +/// use std::process::Command; +/// +/// async fn run_command() { +/// let my_path = "/usr/bin"; +/// let env_vars = environment::get_env_vars(EnvKeys::Path, my_path); +/// let mut command = command!( +/// program => "printenv", +/// envs => env_vars, +/// args => "PATH", +/// ); +/// let output = command.output().await.expect("Failed to execute command"); +/// assert!(output.status.success()); +/// assert_eq!(String::from_utf8_lossy(&output.stdout), "/usr/bin\n"); +/// } +/// ``` +/// +/// # Examples of using the [Run] trait, and [std::process::Output]. +/// +/// ``` +/// use r3bl_script::command; +/// use r3bl_script::command_runner::Run; +/// +/// async fn run_command() { +/// let output = command!( +/// program => "echo", +/// args => "Hello, world!", +/// ) +/// .output() +/// .await +/// .unwrap(); +/// assert!(output.status.success()); +/// +/// let run_bytes = command!( +/// program => "echo", +/// args => "Hello, world!", +/// ) +/// .run() +/// .await +/// .unwrap(); +/// assert_eq!(String::from_utf8_lossy(&run_bytes), "Hello, world!\n"); +/// } +/// ``` +#[macro_export] +macro_rules! command { + // Variant that receives a command and args. + (program=> $cmd:expr, args=> $($args:expr),* $(,)?) => {{ + let mut it = tokio::process::Command::new($cmd); + $( + it.arg($args); + )* + it + }}; + + // Variant that receives a command, env (vec), and args. + (program=> $cmd:expr, envs=> $envs:expr, args=> $($args:expr),* $(,)?) => {{ + let mut it = tokio::process::Command::new($cmd); + it.envs($envs.to_owned()); + // The following is equivalent to the line above: + // it.envs($envs.iter().map(|(k, v)| (k.as_str(), v.as_str()))); + $( + it.arg($args); + )* + it + }}; + } + +pub trait Run { + fn run( + &mut self, + ) -> impl std::future::Future>> + Send; + fn run_interactive( + &mut self, + ) -> impl std::future::Future>> + Send; +} + +impl Run for Command { + async fn run(&mut self) -> miette::Result> { run(self).await } + + async fn run_interactive(&mut self) -> miette::Result> { + run_interactive(self).await + } +} + +#[macro_export] +macro_rules! bail_command_ran_and_failed { + ($command:expr, $status:expr, $stderr:expr) => { + use crossterm::style::Stylize as _; + miette::bail!( + "{name} failed\n{cmd_label}: '{cmd:?}'\n{status_label}: '{status}'\n{stderr_label}: '{stderr}'", + cmd_label = "[command]".to_string().yellow(), + status_label = "[status]".to_string().yellow(), + stderr_label = "[stderr]".to_string().yellow(), + name = stringify!($command).blue(), + cmd = $command, + status = format!("{:?}", $status).magenta(), + stderr = String::from_utf8_lossy(&$stderr).magenta(), + ); + }; + } + +/// This command is not allowed to have user interaction. It does not inherit the +/// `stdin`, `stdout`, `stderr` from the parent (aka current) process. +/// +/// See the tests for examples of how to use this. +pub async fn run(command: &mut Command) -> miette::Result> { + // Try to run command (might be unable to run it if the program is invalid). + let output = command + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .output() + .await + .into_diagnostic() + .wrap_err(miette::miette!("Unable to run command: {:?}", command))?; + + // At this point, command_one has run, but it might result in a success or failure. + if output.status.success() { + ok!(output.stdout) + } else { + bail_command_ran_and_failed!(command, output.status, output.stderr); + } +} + +/// This command is allowed to have full user interaction. It inherits the `stdin`, +/// `stdout`, `stderr` from the parent (aka current) process. +/// +/// See the tests for examples of how to use this. +/// +/// Here's an example which will block on user input from an interactive terminal if +/// executed: +/// +/// ``` +/// use r3bl_script::command; +/// +/// let mut command_one = command!( +/// program => "/usr/bin/bash", +/// args => "-c", "read -p 'Enter your input: ' input" +/// ); +/// ``` +pub async fn run_interactive(command: &mut Command) -> miette::Result> { + // Try to run command (might be unable to run it if the program is invalid). + let output = command + .stdin(Stdio::inherit()) + .stdout(Stdio::inherit()) + .stderr(Stdio::inherit()) + .output() + .await + .into_diagnostic() + .wrap_err(miette::miette!("Unable to run command: {:?}", command))?; + + // At this point, command_one has run, but it might result in a success or failure. + if output.status.success() { + ok!(output.stdout) + } else { + bail_command_ran_and_failed!(command, output.status, output.stderr); + } +} + +/// Mimics the behavior of the Unix pipe operator `|`, ie: `command_one | +/// command_two`. +/// - The output of the first command is passed as input to the second command. +/// - The output of the second command is returned. +/// - If either command fails, an error is returned. +/// +/// Only `command_one` is allowed to have any user interaction. It is set to inherit +/// the `stdin`, `stdout`, `stderr` from the parent (aka current) process. Here's an +/// example which will block on user input from an interactive terminal if executed: +/// +/// ``` +/// use r3bl_script::command; +/// +/// let mut command_one = command!( +/// program => "/usr/bin/bash", +/// args => "-c", "read -p 'Enter your input: ' input" +/// ); +/// ``` +pub async fn pipe( + command_one: &mut Command, + command_two: &mut Command, +) -> miette::Result { + // Run the first command & get the output. + let command_one = command_one + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()); + // Try to run command_one (might be unable to run it if the program is invalid). + let command_one_output = + command_one + .output() + .await + .into_diagnostic() + .wrap_err(miette::miette!( + "Unable to run command_one: {:?}", + command_one + ))?; + // At this point, command_one has run, but it might result in a success or failure. + if !command_one_output.status.success() { + bail_command_ran_and_failed!( + command_one, + command_one_output.status, + command_one_output.stderr + ); + } + let command_one_stdout = command_one_output.stdout; + + // Spawn the second command, make it to accept piped input from the first command. + let command_two = command_two + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()); + // Try to run command_one (might be unable to run it if the program is invalid). + let mut child_handle: Child = + command_two + .spawn() + .into_diagnostic() + .wrap_err(miette::miette!( + "Unable to run command_two: {:?}", + command_two + ))?; + if let Some(mut child_stdin) = child_handle.stdin.take() { + child_stdin + .write_all(&command_one_stdout) + .await + .into_diagnostic()?; + } + // At this point, command_one has run, but it might result in a success or failure. + let command_two_output = child_handle.wait_with_output().await.into_diagnostic()?; + if command_two_output.status.success() { + ok!(String::from_utf8_lossy(&command_two_output.stdout).to_string()) + } else { + bail_command_ran_and_failed!( + command_two, + command_two_output.status, + command_two_output.stderr + ); + } +} + +#[cfg(test)] +mod tests_command_runner { + use super::*; + + #[tokio::test] + async fn test_run() { + let output = command!( + program => "echo", + args => "Hello, world!", + ) + .run() + .await + .unwrap(); + + // This captures the output. + assert_eq!(String::from_utf8_lossy(&output), "Hello, world!\n"); + + // This dumps the output to the parent process' stdout & is captured by + // tokio::process::Command but not by std::process::Command. + let output = command!( + program => "echo", + args => "Hello, world!", + ) + .run_interactive() + .await + .unwrap(); + assert_eq!(String::from_utf8_lossy(&output), "Hello, world!\n"); + } + + #[tokio::test] + async fn test_run_invalid_command() { + let result = command!( + program => "does_not_exist", + args => "Hello, world!", + ) + .run() + .await; + if let Err(err) = result { + assert!(err.to_string().contains("does_not_exist")); + } else { + panic!("Expected an error, but got success"); + } + } + + #[tokio::test] + async fn test_pipe_command_two_not_interactive_terminal() { + let mut command_one = command!( + program => "echo", + args => "hello world", + ); + let mut command_two = command!( + program => "/usr/bin/bash", + args => "-c", "read -p 'Enter your input: ' input" + ); + let result = pipe(&mut command_one, &mut command_two).await; + // This is not an error when using tokio::process::Command. However, when using + // std::process::Command, this will result in an error. + assert_eq!("", result.unwrap()); + } + + #[tokio::test] + async fn test_pipe_invalid_command() { + let result = pipe( + &mut command!( + program => "does_not_exist", + args => "Hello, world!", + ), + &mut command!( + program => "wc", + args => "-w", + ), + ) + .await; + if let Err(err) = result { + assert!(err.to_string().contains("does_not_exist")); + } else { + panic!("Expected an error, but got success"); + } + } +} diff --git a/script/src/directory_change.rs b/script/src/directory_change.rs new file mode 100644 index 000000000..1f92b1a77 --- /dev/null +++ b/script/src/directory_change.rs @@ -0,0 +1,187 @@ +/* + * Copyright (c) 2024 R3BL LLC + * All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +use std::{env, io::ErrorKind, path::Path}; + +use r3bl_core::ok; + +use crate::fs_path::{FsOpError, FsOpResult}; + +/// This macro is used to wrap a block with code that saves the current working directory, +/// runs the block of code for the test, and then restores the original working directory. +/// It also ensures that the test is run serially. +/// +/// Be careful when manipulating the current working directory in tests using +/// [env::set_current_dir] as it can affect other tests that run in parallel. +#[macro_export] +macro_rules! serial_preserve_pwd_test { + ($name:ident, $block:block) => { + #[serial_test::serial] + #[test] + fn $name() { + $crate::with_saved_pwd!($block); + } + }; +} + +/// This macro is used to wrap a block with code that saves the current working directory, +/// runs the block of code for the test, and then restores the original working directory. +/// +/// Use this in conjunction with [serial_test::serial] in order to make sure that multiple +/// threads are not changing the current working directory at the same time (even with +/// this macro). In other words, use this macro [serial_preserve_pwd_test!] for tests. +#[macro_export] +macro_rules! with_saved_pwd { + ($block:block) => {{ + let og_pwd = std::env::current_dir().unwrap(); + let result = { $block }; + std::env::set_current_dir(og_pwd).unwrap(); + result + }}; +} + +/// Change cwd for current process. +pub fn try_cd(new_dir: impl AsRef) -> FsOpResult<()> { + match env::set_current_dir(new_dir.as_ref()) { + Ok(_) => ok!(), + Err(err) => match err.kind() { + ErrorKind::NotFound => { + FsOpResult::Err(FsOpError::DirectoryDoesNotExist(err.to_string())) + } + ErrorKind::PermissionDenied => { + FsOpResult::Err(FsOpError::PermissionDenied(err.to_string())) + } + ErrorKind::InvalidInput => { + FsOpResult::Err(FsOpError::InvalidName(err.to_string())) + } + _ => FsOpResult::Err(FsOpError::IoError(err)), + }, + } +} + +#[cfg(test)] +mod tests_directory_change { + use std::{fs, os::unix::fs::PermissionsExt as _}; + + use r3bl_core::create_temp_dir; + + use super::*; + use crate::{directory_change::try_cd, fs_path::FsOpError, fs_paths}; + + serial_preserve_pwd_test!(test_try_change_directory_permissions_errors, { + with_saved_pwd!({ + // Create the root temp dir. + let root = create_temp_dir().unwrap(); + + // Create a new temporary directory. + let new_tmp_dir = + fs_paths!(with_root: root => "test_change_dir_permissions_errors"); + fs::create_dir_all(&new_tmp_dir).unwrap(); + assert!(new_tmp_dir.exists()); + + // Create a directory with no permissions for user. + let no_permissions_dir = + fs_paths!(with_root: new_tmp_dir => "no_permissions_dir"); + fs::create_dir_all(&no_permissions_dir).unwrap(); + let mut permissions = + fs::metadata(&no_permissions_dir).unwrap().permissions(); + permissions.set_mode(0o000); + fs::set_permissions(&no_permissions_dir, permissions).unwrap(); + assert!(no_permissions_dir.exists()); + // Try to change to a directory with insufficient permissions. + let result = try_cd(&no_permissions_dir); + println!("✅ err: {:?}", result); + assert!(result.is_err()); + assert!(matches!(result, Err(FsOpError::PermissionDenied(_)))); + + // Change the permissions back, so that it can be cleaned up! + let mut permissions = + fs::metadata(&no_permissions_dir).unwrap().permissions(); + permissions.set_mode(0o777); + fs::set_permissions(&no_permissions_dir, permissions).unwrap(); + }); + }); + + serial_preserve_pwd_test!(test_try_change_directory_happy_path, { + with_saved_pwd!({ + // Create the root temp dir. + let root = create_temp_dir().unwrap(); + + // Create a new temporary directory. + let new_tmp_dir = fs_paths!(with_root: root => "test_change_dir_happy_path"); + fs::create_dir_all(&new_tmp_dir).unwrap(); + assert!(new_tmp_dir.exists()); + + // Change to the temporary directory. + try_cd(&new_tmp_dir).unwrap(); + assert_eq!(env::current_dir().unwrap(), new_tmp_dir); + + // Change back to the original directory. + try_cd(&root).unwrap(); + assert_eq!(env::current_dir().unwrap(), *root); + }); + }); + + serial_preserve_pwd_test!(test_try_change_directory_non_existent, { + with_saved_pwd!({ + // Create the root temp dir. + let root = create_temp_dir().unwrap(); + + // Create a new temporary directory. + let new_tmp_dir = + fs_paths!(with_root: root => "test_change_dir_non_existent"); + fs::create_dir_all(&new_tmp_dir).unwrap(); + assert!(new_tmp_dir.exists()); + + // Try to change to a non-existent directory. + let non_existent_dir = + fs_paths!(with_root: new_tmp_dir => "non_existent_dir"); + let result = try_cd(&non_existent_dir); + assert!(result.is_err()); + assert!(matches!(result, Err(FsOpError::DirectoryDoesNotExist(_)))); + + // Change back to the original directory. + try_cd(&root).unwrap(); + assert_eq!(env::current_dir().unwrap(), *root); + }); + }); + + serial_preserve_pwd_test!(test_try_change_directory_invalid_name, { + with_saved_pwd!({ + // Create the root temp dir. + let root = create_temp_dir().unwrap(); + + // Create a new temporary directory. + let new_tmp_dir = + fs_paths!(with_root: root => "test_change_dir_invalid_name"); + fs::create_dir_all(&new_tmp_dir).unwrap(); + assert!(new_tmp_dir.exists()); + + // Try to change to a directory with an invalid name. + let invalid_name_dir = + fs_paths!(with_root: new_tmp_dir => "invalid_name_dir\0"); + let result = try_cd(&invalid_name_dir); + assert!(result.is_err()); + println!("✅ err: {:?}", result); + assert!(matches!(result, Err(FsOpError::InvalidName(_)))); + + // Change back to the original directory. + try_cd(&root).unwrap(); + assert_eq!(env::current_dir().unwrap(), *root); + }); + }); +} diff --git a/script/src/directory_create.rs b/script/src/directory_create.rs new file mode 100644 index 000000000..70ed1f3fa --- /dev/null +++ b/script/src/directory_create.rs @@ -0,0 +1,147 @@ +/* + * Copyright (c) 2024 R3BL LLC + * All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +use std::{fs, io::ErrorKind, path::Path}; + +use r3bl_core::ok; +use strum_macros::Display; + +use crate::{fs_path, + fs_path::{FsOpError, FsOpResult}}; + +#[derive(Debug, Display, Default)] +pub enum MkdirOptions { + #[default] + CreateIntermediateDirectories, + CreateIntermediateDirectoriesOnlyIfNotExists, + CreateIntermediateDirectoriesAndPurgeExisting, +} + +/// Creates a new directory at the specified path. +/// - Depending on the [MkdirOptions] the directories can be created destructively or +/// non-destructively. +/// - Any intermediate folders that don't exist will be created. +/// +/// If any permissions issues occur or the directory can't be created due to +/// inconsistent [MkdirOptions] then an error is returned. +pub fn try_mkdir(new_path: impl AsRef, options: MkdirOptions) -> FsOpResult<()> { + let new_path = new_path.as_ref(); + + // Pre-process the directory creation options. + match options { + // This is the default option. + MkdirOptions::CreateIntermediateDirectories => { /* Do nothing. */ } + + // This will delete the directory if it exists and then create it. + MkdirOptions::CreateIntermediateDirectoriesAndPurgeExisting => { + match fs::exists(new_path) { + // The new_path exists. + Ok(true) => { + // Remove the entire new_path. + if let Err(err) = fs::remove_dir_all(new_path) { + return handle_err(err); + } + } + // Encountered problem checking if the new_path exists. + Err(err) => return handle_err(err), + // The new_path does not exist. + _ => { /* Do nothing. */ } + } + } + + // This will error out if the directory already exists. + MkdirOptions::CreateIntermediateDirectoriesOnlyIfNotExists => { + if let Ok(true) = fs::exists(new_path) { + let new_dir_display = fs_path::path_as_string(new_path); + return FsOpResult::Err(FsOpError::DirectoryAlreadyExists( + new_dir_display, + )); + } + } + } + + // Create the path. + create_dir_all(new_path) +} + +fn handle_err(err: std::io::Error) -> FsOpResult<()> { + match err.kind() { + ErrorKind::PermissionDenied => { + FsOpResult::Err(FsOpError::PermissionDenied(err.to_string())) + } + ErrorKind::InvalidInput => { + FsOpResult::Err(FsOpError::InvalidName(err.to_string())) + } + ErrorKind::ReadOnlyFilesystem => { + FsOpResult::Err(FsOpError::PermissionDenied(err.to_string())) + } + _ => FsOpResult::Err(FsOpError::IoError(err)), + } +} + +fn create_dir_all(new_path: &Path) -> FsOpResult<()> { + match fs::create_dir_all(new_path) { + Ok(_) => ok!(), + Err(err) => handle_err(err), + } +} + +#[cfg(test)] +mod tests_directory_create { + use r3bl_core::create_temp_dir; + + use super::*; + use crate::{directory_create::{MkdirOptions::*, try_mkdir}, + fs_paths, + serial_preserve_pwd_test, + with_saved_pwd}; + + serial_preserve_pwd_test!(test_try_mkdir, { + with_saved_pwd!({ + // Create the root temp dir. + let root = create_temp_dir().unwrap(); + + // Create a temporary directory. + let tmp_root_dir = fs_paths!(with_root: root => "test_create_clean_new_dir"); + try_mkdir(&tmp_root_dir, CreateIntermediateDirectories).unwrap(); + + // Create a new directory inside the temporary directory. + let new_dir = fs_paths!(with_root: tmp_root_dir => "new_dir"); + try_mkdir(&new_dir, CreateIntermediateDirectories).unwrap(); + assert!(new_dir.exists()); + + // Try & fail to create the same directory again non destructively. + let result = + try_mkdir(&new_dir, CreateIntermediateDirectoriesOnlyIfNotExists); + assert!(result.is_err()); + assert!(matches!(result, Err(FsOpError::DirectoryAlreadyExists(_)))); + + // Create a file inside the new directory. + let file_path = new_dir.join("test_file.txt"); + fs::write(&file_path, "test").unwrap(); + assert!(file_path.exists()); + + // Call `mkdir` again with destructive options and ensure the directory is + // clean. + try_mkdir(&new_dir, CreateIntermediateDirectoriesAndPurgeExisting).unwrap(); + + // Ensure the directory is clean. + assert!(new_dir.exists()); + assert!(!file_path.exists()); + }); + }); +} diff --git a/script/src/download.rs b/script/src/download.rs new file mode 100644 index 000000000..53367f18f --- /dev/null +++ b/script/src/download.rs @@ -0,0 +1,79 @@ +/* + * Copyright (c) 2024 R3BL LLC + * All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +use std::{fs, io::Write as _, path::Path}; + +use miette::IntoDiagnostic; +use r3bl_core::ok; + +use crate::http_client::create_client_with_user_agent; + +pub async fn try_download_file_overwrite_existing( + source_url: &str, + destination_file: impl AsRef, +) -> miette::Result<()> { + let destination = destination_file.as_ref(); + + let client = create_client_with_user_agent(None)?; + let response = client.get(source_url).send().await.into_diagnostic()?; + let response = response.error_for_status().into_diagnostic()?; + let response = response.bytes().await.into_diagnostic()?; + + let mut dest_file = fs::File::create(destination).into_diagnostic()?; + dest_file.write_all(&response).into_diagnostic()?; + + ok!() +} + +#[cfg(test)] +mod tests_download { + use r3bl_core::create_temp_dir; + + use super::*; + + #[tokio::test] + async fn test_download_file_overwrite_existing() { + // Create the root temp dir. + let root = create_temp_dir().unwrap(); + + let new_dir = root.join("test_download_file_overwrite_existing"); + fs::create_dir_all(&new_dir).unwrap(); + + let source_url = "https://github.com/cloudflare/cfssl/releases/download/v1.6.5/cfssljson_1.6.5_linux_amd64"; + let destination_file = new_dir.join("cfssljson"); + + // Download file (no pre-existing file). + try_download_file_overwrite_existing(source_url, &destination_file) + .await + .unwrap(); + assert!(destination_file.exists()); + + let meta_data = destination_file.metadata().unwrap(); + let og_file_size = meta_data.len(); + + // Download file again (overwrite existing). + try_download_file_overwrite_existing(source_url, &destination_file) + .await + .unwrap(); + assert!(destination_file.exists()); + + // Ensure that the file sizes are the same. + let meta_data = destination_file.metadata().unwrap(); + let new_file_size = meta_data.len(); + assert_eq!(og_file_size, new_file_size); + } +} diff --git a/script/src/environment.rs b/script/src/environment.rs new file mode 100644 index 000000000..6624390f3 --- /dev/null +++ b/script/src/environment.rs @@ -0,0 +1,124 @@ +/* + * Copyright (c) 2024 R3BL LLC + * All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +use std::{env, path::Path}; + +use miette::IntoDiagnostic; +use r3bl_core::ok; +use strum_macros::{Display, EnumString}; + +#[cfg(target_os = "windows")] +const OS_SPECIFIC_ENV_PATH_SEPARATOR: &str = ";"; +#[cfg(not(target_os = "windows"))] +const OS_SPECIFIC_ENV_PATH_SEPARATOR: &str = ":"; + +#[derive(Debug, Display, EnumString)] +pub enum EnvKeys { + #[strum(serialize = "PATH")] + Path, +} + +pub type EnvVars = Vec<(String, String)>; +pub type EnvVarsSlice<'a> = &'a [(String, String)]; + +/// Returns the PATH environment variable as a vector of tuples. +/// +/// # Example +/// +/// ``` +/// use r3bl_script::environment::{get_env_vars, EnvKeys}; +/// +/// let path_envs = get_env_vars(EnvKeys::Path, "/usr/bin"); +/// let expected = vec![ +/// ("PATH".to_string(), "/usr/bin".to_string()) +/// ]; +/// assert_eq!(path_envs, expected); +/// ``` +/// +/// # Example of using the returned value as a slice +/// +/// The returned value can also be passed around as a `&[(String, String)]`. +/// +/// ``` +/// use r3bl_script::environment::{get_env_vars, EnvVars, EnvVarsSlice, EnvKeys}; +/// +/// let path_envs: EnvVars = get_env_vars(EnvKeys::Path, "/usr/bin"); +/// let path_envs_ref: EnvVarsSlice = &path_envs; +/// let path_envs_ref_2 = path_envs.as_slice(); +/// let path_envs_ref_clone = path_envs_ref.to_owned(); +/// assert_eq!(path_envs_ref, path_envs_ref_clone); +/// assert_eq!(path_envs_ref, path_envs_ref_2); +/// ``` +pub fn get_env_vars(key: EnvKeys, path: &str) -> EnvVars { + vec![(key.to_string(), path.to_string())] +} + +pub fn try_get(key: EnvKeys) -> miette::Result { + env::var(key.to_string()).into_diagnostic() +} + +pub fn try_get_path_prefixed(prefix_path: impl AsRef) -> miette::Result { + let path = try_get(EnvKeys::Path)?; + let add_to_path: String = format!( + "{}{}{}", + prefix_path.as_ref().display(), + OS_SPECIFIC_ENV_PATH_SEPARATOR, + path + ); + // % is Display, ? is Debug. + tracing::debug!("my_path" = %add_to_path); + ok!(add_to_path) +} + +#[cfg(test)] +mod tests_environment { + use super::*; + use crate::environment; + + #[test] + fn test_try_get_path_from_env() { + let path = environment::try_get(EnvKeys::Path).unwrap(); + assert!(!path.is_empty()); + } + + #[test] + fn test_try_get() { + let path = environment::try_get(EnvKeys::Path).unwrap(); + assert!(!path.is_empty()); + } + + #[test] + fn test_get_path_envs() { + let path_envs = environment::get_env_vars(EnvKeys::Path, "/usr/bin"); + let expected = vec![("PATH".to_string(), "/usr/bin".to_string())]; + assert_eq!(path_envs, expected); + } + + #[test] + fn test_get_path() { + let path = environment::try_get(EnvKeys::Path).unwrap(); + assert!(!path.is_empty()); + } + + #[test] + fn test_get_path_prefixed() { + let prefix_path = "/usr/bin"; + let path = environment::try_get_path_prefixed(prefix_path).unwrap(); + assert!(!path.is_empty()); + assert!(path.starts_with(prefix_path)); + } +} diff --git a/script/src/fs_path.rs b/script/src/fs_path.rs new file mode 100644 index 000000000..24afe387b --- /dev/null +++ b/script/src/fs_path.rs @@ -0,0 +1,459 @@ +/* + * Copyright (c) 2024 R3BL LLC + * All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +//! Note that [PathBuf] is owned and [Path] is a slice into it. +//! - So replace `&`[PathBuf] with a `&`[Path]. +//! - More details +//! [here](https://rust-lang.github.io/rust-clippy/master/index.html#ptr_arg). + +use std::{env, + fs, + io::ErrorKind, + path::{Path, PathBuf}}; + +use miette::Diagnostic; +use r3bl_core::ok; +use thiserror::Error; + +/// Use this macro to make it more ergonomic to work with [PathBuf]s. +/// +/// # Example - create a new path +/// +/// ``` +/// use r3bl_script::fs_paths; +/// use std::path::{PathBuf, Path}; +/// +/// let my_path = fs_paths![with_empty_root => "usr/bin" => "bash"]; +/// assert_eq!(my_path, PathBuf::from("usr/bin/bash")); +/// +/// let my_path = fs_paths![with_empty_root => "usr" => "bin" => "bash"]; +/// assert_eq!(my_path, PathBuf::from("usr/bin/bash")); +/// ``` +/// +/// # Example - join to an existing path +/// +/// ``` +/// use r3bl_script::fs_paths; +/// use std::path::{PathBuf, Path}; +/// +/// let root = PathBuf::from("/home/user"); +/// let my_path = fs_paths![with_root: root => "Downloads" => "rust"]; +/// assert_eq!(my_path, PathBuf::from("/home/user/Downloads/rust")); +/// +/// let root = PathBuf::from("/home/user"); +/// let my_path = fs_paths![with_root: root => "Downloads" => "rust"]; +/// assert_eq!(my_path, PathBuf::from("/home/user/Downloads/rust")); +/// ``` +#[macro_export] +macro_rules! fs_paths { + // Join to an existing root path. + (with_root: $path:expr=> $($x:expr)=>*) => {{ + let mut it: std::path::PathBuf = $path.to_path_buf(); + $( + it = it.join($x); + )* + it + }}; + + // Create a new path w/ no pre-existing root. + (with_empty_root=> $($x:expr)=>*) => {{ + use std::path::{PathBuf}; + let mut it = PathBuf::new(); + $( + it = it.join($x); + )* + it + }} +} + +/// Use this macro to ensure that all the paths provided exist on the filesystem, in which +/// case it will return true If any of the paths do not exist, the function will return +/// false. No error will be returned in case any of the paths are invalid or there aren't +/// enough permissions to check if the paths exist. +/// +/// # Example +/// +/// ``` +/// use r3bl_script::fs_paths_exist; +/// use r3bl_script::fs_paths; +/// use r3bl_core::create_temp_dir; +/// +/// let temp_dir = create_temp_dir().unwrap(); +/// let path_1 = fs_paths![with_root: temp_dir => "some_dir"]; +/// let path_2 = fs_paths![with_root: temp_dir => "another_dir"]; +/// +/// assert!(!fs_paths_exist!(path_1, path_2)); +/// ``` +#[macro_export] +macro_rules! fs_paths_exist { + ($($x:expr),*) => {'block: { + $( + if !std::fs::metadata($x).is_ok() { + break 'block false; + }; + )* + true + }}; +} + +#[derive(Debug, Error, Diagnostic)] +pub enum FsOpError { + #[error("File does not exist: {0}")] + FileDoesNotExist(String), + + #[error("Directory does not exist: {0}")] + DirectoryDoesNotExist(String), + + #[error("File already exists: {0}")] + FileAlreadyExists(String), + + #[error("Directory already exists: {0}")] + DirectoryAlreadyExists(String), + + #[error("Insufficient permissions: {0}")] + PermissionDenied(String), + + #[error("Invalid name: {0}")] + InvalidName(String), + + #[error("Failed to perform fs operation directory: {0}")] + IoError(#[from] std::io::Error), +} + +pub type FsOpResult = miette::Result; + +/// Checks whether the directory exist. If won't provide any errors if there are +/// permissions issues or the directory is invalid. Use [try_directory_exists] if you +/// want to handle these errors. +pub fn directory_exists(directory: impl AsRef) -> bool { + fs::metadata(directory).is_ok_and(|metadata| metadata.is_dir()) +} + +/// Checks whether the file exists. If won't provide any errors if there are permissions +/// issues or the file is invalid. Use [try_file_exists] if you want to handle these +/// errors. +pub fn file_exists(file: impl AsRef) -> bool { + fs::metadata(file).is_ok_and(|metadata| metadata.is_file()) +} + +/// Checks whether the directory exist. If there are issues with permissions for +/// directory access or invalid directory it will return an error. Use +/// [directory_exists] if you want to ignore these errors. +pub fn try_directory_exists(directory_path: impl AsRef) -> FsOpResult { + match fs::metadata(directory_path) { + Ok(metadata) => { + // The directory_path might be found in the file system, but it might be a + // file. This won't result in an error. + ok!(metadata.is_dir()) + } + Err(err) => match err.kind() { + ErrorKind::NotFound => { + FsOpResult::Err(FsOpError::DirectoryDoesNotExist(err.to_string())) + } + ErrorKind::InvalidInput => { + FsOpResult::Err(FsOpError::InvalidName(err.to_string())) + } + _ => FsOpResult::Err(FsOpError::IoError(err)), + }, + } +} + +/// Checks whether the file exist. If there are issues with permissions for file access +/// or invalid file it will return an error. Use [file_exists] if you want to ignore +/// these errors. +pub fn try_file_exists(file_path: impl AsRef) -> FsOpResult { + match fs::metadata(file_path) { + // The file_path might be found in the file system, but it might be a + // directory. This won't result in an error. + Ok(metadata) => ok!(metadata.is_file()), + Err(err) => match err.kind() { + ErrorKind::NotFound => { + FsOpResult::Err(FsOpError::FileDoesNotExist(err.to_string())) + } + ErrorKind::InvalidInput => { + FsOpResult::Err(FsOpError::InvalidName(err.to_string())) + } + _ => FsOpResult::Err(FsOpError::IoError(err)), + }, + } +} + +/// Returns the current working directory of the process as a [PathBuf] (owned). If +/// there are issues with permissions for directory access or invalid directory it +/// will return an error. +/// +/// - `bash` equivalent: `$(pwd)` +/// - Eg: `PathBuf("/home/user/some/path")` +pub fn try_pwd() -> FsOpResult { + match env::current_dir() { + Ok(pwd) => FsOpResult::Ok(pwd), + Err(err) => match err.kind() { + ErrorKind::NotFound => { + FsOpResult::Err(FsOpError::DirectoryDoesNotExist(err.to_string())) + } + _ => FsOpResult::Err(FsOpError::IoError(err)), + }, + } +} + +/// Returns the [Path] slice as a string. +/// - Eg: `"/home/user/some/path"` +pub fn path_as_string(path: &Path) -> String { path.display().to_string() } + +#[cfg(test)] +mod tests_fs_path { + use std::os::unix::fs::PermissionsExt as _; + + use fs_path::try_pwd; + use r3bl_core::create_temp_dir; + + use super::*; + use crate::{fs_path, serial_preserve_pwd_test, with_saved_pwd}; + + serial_preserve_pwd_test!(test_try_pwd, { + with_saved_pwd!({ + // Create the root temp dir. + let root = create_temp_dir().unwrap(); + + let new_dir = root.join("test_pwd"); + fs::create_dir_all(&new_dir).unwrap(); + env::set_current_dir(&new_dir).unwrap(); + + let pwd = try_pwd().unwrap(); + assert!(pwd.exists()); + assert_eq!(pwd, new_dir); + }); + }); + + serial_preserve_pwd_test!(test_try_pwd_errors, { + with_saved_pwd!({ + // Create the root temp dir. + let root = create_temp_dir().unwrap(); + + // Create a directory, change to it, remove all permissions for user. + let no_permissions_dir = root.join("no_permissions_dir"); + fs::create_dir_all(&no_permissions_dir).unwrap(); + env::set_current_dir(&no_permissions_dir).unwrap(); + let mut permissions = + fs::metadata(&no_permissions_dir).unwrap().permissions(); + permissions.set_mode(0o000); + fs::set_permissions(&no_permissions_dir, permissions).unwrap(); + assert!(no_permissions_dir.exists()); + + // Try to get the pwd with insufficient permissions. It should work! + let result = try_pwd(); + assert!(result.is_ok()); + + // Change the permissions back, so that it can be cleaned up! + let mut permissions = + fs::metadata(&no_permissions_dir).unwrap().permissions(); + permissions.set_mode(0o777); + fs::set_permissions(&no_permissions_dir, permissions).unwrap(); + + // Delete this directory, and try pwd again. It will not longer exist. + fs::remove_dir_all(&no_permissions_dir).unwrap(); + let result = try_pwd(); + assert!(result.is_err()); + assert!(matches!(result, Err(FsOpError::DirectoryDoesNotExist(_)))); + }); + }); + + serial_preserve_pwd_test!(test_fq_path_relative_to_try_pwd, { + with_saved_pwd!({ + // Create the root temp dir. + let root = create_temp_dir().unwrap(); + + let sub_path = "test_fq_path_relative_to_pwd"; + let new_dir = root.join(sub_path); + fs::create_dir_all(&new_dir).unwrap(); + + env::set_current_dir(&root).unwrap(); + + println!("Current directory set to: {}", root); + println!("Current directory is : {}", try_pwd().unwrap().display()); + + let fq_path = fs_paths!(with_root: try_pwd().unwrap() => sub_path); + + println!("Sub directory created at: {}", fq_path.display()); + println!("Sub directory exists : {}", fq_path.exists()); + + assert!(fq_path.exists()); + }); + }); + + serial_preserve_pwd_test!(test_path_as_string, { + with_saved_pwd!({ + // Create the root temp dir. + let root = create_temp_dir().unwrap(); + + env::set_current_dir(&root).unwrap(); + + let fq_path = fs_paths!(with_root: try_pwd().unwrap() => "some_dir"); + let fq_path_str = fs_path::path_as_string(&fq_path); + + assert_eq!(fq_path_str, fq_path.display().to_string()); + }); + }); + + serial_preserve_pwd_test!(test_try_file_exists, { + with_saved_pwd!({ + // Create the root temp dir. + let root = create_temp_dir().unwrap(); + + let new_dir = root.join("test_file_exists_dir"); + fs::create_dir_all(&new_dir).unwrap(); + + let new_file = new_dir.join("test_file_exists_file.txt"); + fs::write(&new_file, "test").unwrap(); + + assert!(fs_path::try_file_exists(&new_file).unwrap()); + assert!(!fs_path::try_file_exists(&new_dir).unwrap()); + + fs::remove_dir_all(&new_dir).unwrap(); + + // Ensure that an invalid path returns an error. + assert!(fs_path::try_file_exists(&new_file).is_err()); // This file does not exist. + assert!(fs_path::try_file_exists(&new_dir).is_err()); // This directory does not exist. + }); + }); + + serial_preserve_pwd_test!(test_try_file_exists_not_found_error, { + with_saved_pwd!({ + // Create the root temp dir. + let root = create_temp_dir().unwrap(); + + let new_dir = root.join("test_file_exists_not_found_error"); + + // Try to check if the file exists. It should return an error. + let result = fs_path::try_file_exists(&new_dir); + assert!(result.is_err()); + assert!(matches!(result, Err(FsOpError::FileDoesNotExist(_)))); + }); + }); + + serial_preserve_pwd_test!(test_try_file_exists_invalid_name_error, { + with_saved_pwd!({ + // Create the root temp dir. + let root = create_temp_dir().unwrap(); + + let new_dir = root.join("test_file_exists_invalid_name_error\0"); + + // Try to check if the file exists. It should return an error. + let result = fs_path::try_file_exists(&new_dir); + assert!(result.is_err()); + assert!(matches!(result, Err(FsOpError::InvalidName(_)))); + }); + }); + + serial_preserve_pwd_test!(test_try_file_exists_permissions_errors, { + with_saved_pwd!({ + // Create the root temp dir. + let root = create_temp_dir().unwrap(); + + // Create a directory, change to it, remove all permissions for user. + let no_permissions_dir = root.join("no_permissions_dir"); + fs::create_dir_all(&no_permissions_dir).unwrap(); + let mut permissions = + fs::metadata(&no_permissions_dir).unwrap().permissions(); + permissions.set_mode(0o000); + fs::set_permissions(&no_permissions_dir, permissions).unwrap(); + assert!(no_permissions_dir.exists()); + + // Try to check if the file exists with insufficient permissions. It should + // work! + let result = fs_path::try_file_exists(&no_permissions_dir); + assert!(result.is_ok()); + + // Change the permissions back, so that it can be cleaned up! + let mut permissions = + fs::metadata(&no_permissions_dir).unwrap().permissions(); + permissions.set_mode(0o777); + fs::set_permissions(&no_permissions_dir, permissions).unwrap(); + }); + }); + + serial_preserve_pwd_test!(test_try_directory_exists, { + with_saved_pwd!({ + // Create the root temp dir. + let root = create_temp_dir().unwrap(); + + let new_dir = root.join("test_dir_exists_dir"); + fs::create_dir_all(&new_dir).unwrap(); + + let new_file = new_dir.join("test_dir_exists_file.txt"); + fs::write(&new_file, "test").unwrap(); + + assert!(fs_path::try_directory_exists(&new_dir).unwrap()); + assert!(!fs_path::try_directory_exists(&new_file).unwrap()); + }) + }); + + serial_preserve_pwd_test!(test_try_directory_exists_not_found_error, { + with_saved_pwd!({ + // Create the root temp dir. + let root = create_temp_dir().unwrap(); + + let new_dir = root.join("test_dir_exists_not_found_error"); + + // Try to check if the directory exists. It should return an error. + let result = fs_path::try_directory_exists(&new_dir); + assert!(result.is_err()); + assert!(matches!(result, Err(FsOpError::DirectoryDoesNotExist(_)))); + }); + }); + + serial_preserve_pwd_test!(test_try_directory_exists_invalid_name_error, { + with_saved_pwd!({ + // Create the root temp dir. + let root = create_temp_dir().unwrap(); + + let new_dir = root.join("test_dir_exists_invalid_name_error\0"); + + // Try to check if the directory exists. It should return an error. + let result = fs_path::try_directory_exists(&new_dir); + assert!(result.is_err()); + assert!(matches!(result, Err(FsOpError::InvalidName(_)))); + }); + }); + + serial_preserve_pwd_test!(test_try_directory_exists_permissions_errors, { + with_saved_pwd!({ + // Create the root temp dir. + let root = create_temp_dir().unwrap(); + + // Create a directory, change to it, remove all permissions for user. + let no_permissions_dir = root.join("no_permissions_dir"); + fs::create_dir_all(&no_permissions_dir).unwrap(); + let mut permissions = + fs::metadata(&no_permissions_dir).unwrap().permissions(); + permissions.set_mode(0o000); + fs::set_permissions(&no_permissions_dir, permissions).unwrap(); + assert!(no_permissions_dir.exists()); + + // Try to check if the directory exists with insufficient permissions. It + // should work! + let result = fs_path::try_directory_exists(&no_permissions_dir); + assert!(result.is_ok()); + + // Change the permissions back, so that it can be cleaned up! + let mut permissions = + fs::metadata(&no_permissions_dir).unwrap().permissions(); + permissions.set_mode(0o777); + fs::set_permissions(&no_permissions_dir, permissions).unwrap(); + }); + }); +} diff --git a/script/src/github_api.rs b/script/src/github_api.rs new file mode 100644 index 000000000..1b27873ed --- /dev/null +++ b/script/src/github_api.rs @@ -0,0 +1,82 @@ +/* + * Copyright (c) 2024 R3BL LLC + * All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +use crossterm::style::Stylize as _; +use miette::IntoDiagnostic; +use r3bl_core::ok; + +use crate::http_client; + +mod constants { + pub const TAG_NAME: &str = "tag_name"; + pub const VERSION_PREFIX: &str = "v"; +} + +pub mod urls { + pub const REPO_LATEST_RELEASE: &str = + "https://api.github.com/repos/{org}/{repo}/releases/latest"; +} + +pub async fn try_get_latest_release_tag_from_github( + org: &str, + repo: &str, +) -> miette::Result { + let url = urls::REPO_LATEST_RELEASE + .replace("{org}", org) + .replace("{repo}", repo); + + // % is Display, ? is Debug. + tracing::debug!( + "Fetching latest release tag from GitHub" = %url.to_string().magenta() + ); + + let client = http_client::create_client_with_user_agent(None)?; + let response = client.get(url).send().await.into_diagnostic()?; + let response = response.error_for_status().into_diagnostic()?; // Return an error if the status != 2xx. + let response: serde_json::Value = response.json().await.into_diagnostic()?; + + let tag_name = match response[constants::TAG_NAME].as_str() { + Some(tag_name) => tag_name.trim_start_matches(constants::VERSION_PREFIX), + None => miette::bail!("Failed to get tag name from JSON: {:?}", response), + }; + + ok!(tag_name.to_owned()) +} + +#[cfg(test)] +mod tests_github_api { + use r3bl_ansi_color::{TTYResult, is_fully_uninteractive_terminal}; + + use super::*; + use crate::github_api::try_get_latest_release_tag_from_github; + + /// Do not run this in CI/CD since it makes API calls to github.com. + #[tokio::test] + async fn test_get_latest_tag_from_github() { + // This is for CI/CD. + if let TTYResult::IsNotInteractive = is_fully_uninteractive_terminal() { + return; + } + let org = "cloudflare"; + let repo = "cfssl"; + let tag = try_get_latest_release_tag_from_github(org, repo) + .await + .unwrap(); + assert!(!tag.is_empty()); + println!("Latest tag: {}", tag.magenta()); + } +} diff --git a/script/src/http_client.rs b/script/src/http_client.rs new file mode 100644 index 000000000..e388fd419 --- /dev/null +++ b/script/src/http_client.rs @@ -0,0 +1,34 @@ +/* + * Copyright (c) 2024 R3BL LLC + * All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +use miette::IntoDiagnostic; + +mod constants { + pub const USER_AGENT: &str = "scripting.rs/1.0"; +} + +pub fn create_client_with_user_agent( + user_agent: Option<&str>, +) -> miette::Result { + let it = reqwest::Client::builder() + .user_agent(user_agent.map_or_else( + || constants::USER_AGENT.to_owned(), + |user_agent| user_agent.to_owned(), + )) + .build(); + it.into_diagnostic() +} diff --git a/script/src/lib.rs b/script/src/lib.rs new file mode 100644 index 000000000..0fb4b3888 --- /dev/null +++ b/script/src/lib.rs @@ -0,0 +1,40 @@ +/* + * Copyright (c) 2024 R3BL LLC + * All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +// Attach sources. +pub mod apt_install; +pub mod command_runner; +pub mod directory_change; +pub mod directory_create; +pub mod download; +pub mod environment; +pub mod fs_path; +pub mod github_api; +pub mod http_client; +pub mod permissions; + +// Re-export. +pub use apt_install::*; +pub use command_runner::*; +pub use directory_change::*; +pub use directory_create::*; +pub use download::*; +pub use environment::*; +pub use fs_path::*; +pub use github_api::*; +pub use http_client::*; +pub use permissions::*; diff --git a/script/src/permissions.rs b/script/src/permissions.rs new file mode 100644 index 000000000..8654bb7fa --- /dev/null +++ b/script/src/permissions.rs @@ -0,0 +1,95 @@ +/* + * Copyright (c) 2024 R3BL LLC + * All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +use std::{fs, os::unix::fs::PermissionsExt as _, path::Path}; + +use miette::IntoDiagnostic; + +/// Sets the file at the specified path to be executable by owner, group, and others. +/// - `bash` equivalent: `chmod +x file` +/// - Eg: `set_file_executable("some_file.sh")` +/// - The `file` must exist and be a file (not a directory). +pub fn try_set_file_executable(file: impl AsRef) -> miette::Result<()> { + let file = file.as_ref(); + let metadata = fs::metadata(file).into_diagnostic()?; + + if !metadata.is_file() { + miette::bail!("This is not a file: '{}'", file.display()); + } + + // Set execute permissions for owner, group, and others on this file. 755 means: + // - 7 (owner): read (4) + write (2) + execute (1) = 7 (rwx) + // - 5 (group): read (4) + execute (1) = 5 (r-x) + // - 5 (others): read (4) + execute (1) = 5 (r-x) + fs::set_permissions(file, std::fs::Permissions::from_mode(0o755)).into_diagnostic() +} + +#[cfg(test)] +mod tests_permissions { + use r3bl_core::create_temp_dir; + + use super::*; + + #[test] + fn test_set_file_executable() { + // Create the root temp dir. + let root = create_temp_dir().unwrap(); + + let new_dir = root.join("test_set_file_executable"); + fs::create_dir_all(&new_dir).unwrap(); + + let new_file = new_dir.join("test_set_file_executable.sh"); + fs::write(&new_file, "echo 'Hello, World!'").unwrap(); + + try_set_file_executable(&new_file).unwrap(); + + let metadata = fs::metadata(&new_file).unwrap(); + let lhs = metadata.permissions(); + + // Assert that the file has executable permission for owner, group, and others: + // - The bitwise AND operation (lhs.mode() & 0o777) ensures that only the + // permission bits are compared, ignoring other bits that might be present in + // the mode. + // - The assertion checks if the permission bits match 0o755. + assert_eq!(lhs.mode() & 0o777, 0o755); + } + + #[test] + fn test_set_file_executable_on_non_file() { + // Create the root temp dir. + let root = create_temp_dir().unwrap(); + + let new_dir = root.join("test_set_file_executable_on_non_file"); + fs::create_dir_all(&new_dir).unwrap(); + + let result = try_set_file_executable(&new_dir); + assert!(result.is_err()); + } + + #[test] + fn test_set_file_executable_on_non_existent_file() { + // Create the root temp dir. + let root = create_temp_dir().unwrap(); + + let new_dir = root.join("test_set_file_executable_on_non_existent_file"); + fs::create_dir_all(&new_dir).unwrap(); + + let non_existent_file = new_dir.join("non_existent_file.sh"); + let result = try_set_file_executable(&non_existent_file); + assert!(result.is_err()); + } +} diff --git a/terminal_async/Cargo.toml b/terminal_async/Cargo.toml index 59cdd4d58..3ef409a95 100644 --- a/terminal_async/Cargo.toml +++ b/terminal_async/Cargo.toml @@ -3,7 +3,7 @@ name = "r3bl_terminal_async" version = "0.6.0" edition = "2021" resolver = "2" -description = "Async non-blocking read_line implemenation with multiline editor, with concurrent display output from tasks, and colorful animated spinners" +description = "Async non-blocking read_line implementation with multiline editor, with concurrent display output from tasks, and colorful animated spinners" # At most 5 keywords w/ no spaces, each has max length of 20 char. keywords = ["cli", "spinner", "readline", "terminal", "async"] categories = ["command-line-interface", "command-line-utilities"] @@ -25,9 +25,10 @@ futures-util = "0.3.31" # Needed for cro # r3bl-open-core. r3bl_ansi_color = { path = "../ansi_color", version = "0.7.0" } # version is required to publish to crates.io -r3bl_core = { path = "../core", version = "0.10.0" } # version is requried to publish to crates.io -r3bl_tui = { path = "../tui", version = "0.6.0" } # version is requried to publish to crates.io -r3bl_tuify = { path = "../tuify", version = "0.2.0" } # version is requried to publish to crates.io +r3bl_core = { path = "../core", version = "0.10.0" } # version is required to publish to crates.io +r3bl_tui = { path = "../tui", version = "0.6.0" } # version is required to publish to crates.io +r3bl_tuify = { path = "../tuify", version = "0.2.0" } # version is required to publish to crates.io +r3bl_log = { path = "../log", version = "0.1.0" } # version is required to publish to crates.io # Unicode support. unicode-segmentation = "1.12.0" diff --git a/terminal_async/examples/terminal_async.rs b/terminal_async/examples/terminal_async.rs index b38a87b53..76a0f0ad3 100644 --- a/terminal_async/examples/terminal_async.rs +++ b/terminal_async/examples/terminal_async.rs @@ -25,11 +25,8 @@ use std::{fs, use crossterm::style::Stylize as _; use miette::{miette, IntoDiagnostic as _}; -use r3bl_core::{tracing_logging::tracing_config::TracingConfig, - DisplayPreference, - SendRawTerminal, - SharedWriter, - StdMutex}; +use r3bl_core::{SendRawTerminal, SharedWriter, StdMutex}; +use r3bl_log::{tracing_config::TracingConfig, DisplayPreference}; use r3bl_terminal_async::{Readline, ReadlineEvent, Spinner, diff --git a/test_fixtures/src/lib.rs b/test_fixtures/src/lib.rs index 55a69e01d..8ebf75e2a 100644 --- a/test_fixtures/src/lib.rs +++ b/test_fixtures/src/lib.rs @@ -197,10 +197,8 @@ pub mod input_device_fixtures; pub mod output_device_fixtures; pub mod tcp_stream_fixtures; -pub mod temp_dir; // Re-export. pub use input_device_fixtures::*; pub use output_device_fixtures::*; pub use tcp_stream_fixtures::*; -pub use temp_dir::*; diff --git a/tui/Cargo.toml b/tui/Cargo.toml index 53b3442be..133cea6f6 100644 --- a/tui/Cargo.toml +++ b/tui/Cargo.toml @@ -26,9 +26,10 @@ path = "src/lib.rs" [dependencies] # r3bl-open-core. -r3bl_core = { path = "../core", version = "0.10.0" } # version is requried to publish to crates.io -r3bl_macro = { path = "../macro", version = "0.10.0" } # version is requried to publish to crates.io -r3bl_ansi_color = { path = "../ansi_color", version = "0.7.0" } # version is requried to publish to crates.io +r3bl_core = { path = "../core", version = "0.10.0" } # version is required to publish to crates.io +r3bl_macro = { path = "../macro", version = "0.10.0" } # version is required to publish to crates.io +r3bl_ansi_color = { path = "../ansi_color", version = "0.7.0" } # version is required to publish to crates.io +r3bl_log = { path = "../log", version = "0.1.0" } # version is required to publish to crates.io # Time chrono = "0.4.38" diff --git a/tui/examples/demo/main.rs b/tui/examples/demo/main.rs index 1427466d6..71c6f24b2 100644 --- a/tui/examples/demo/main.rs +++ b/tui/examples/demo/main.rs @@ -37,12 +37,8 @@ use std::str::FromStr as _; use crossterm::style::Stylize as _; use miette::IntoDiagnostic as _; -use r3bl_core::{logging::try_initialize_global_logging, - ok, - style_prompt, - throws, - CommonError, - CommonResult}; +use r3bl_core::{ok, style_prompt, throws, CommonError, CommonResult}; +use r3bl_log::log_support::try_initialize_logging_global; use r3bl_terminal_async::{ReadlineEvent, TerminalAsync}; use r3bl_tui::{keypress, InputEvent, TerminalWindow, DEBUG_TUI_MOD}; use strum::IntoEnumIterator as _; @@ -71,9 +67,9 @@ async fn main() -> CommonResult<()> { // Ignore errors: https://doc.rust-lang.org/std/result/enum.Result.html#method.ok if ENABLE_TRACE_EXAMPLES | DEBUG_TUI_MOD { - try_initialize_global_logging(tracing_core::LevelFilter::DEBUG).ok(); + try_initialize_logging_global(tracing_core::LevelFilter::DEBUG).ok(); } else { - try_initialize_global_logging(tracing_core::LevelFilter::OFF).ok(); + try_initialize_logging_global(tracing_core::LevelFilter::OFF).ok(); } loop { diff --git a/tui/src/tui/editor/editor_buffer/editor_buffer_struct.rs b/tui/src/tui/editor/editor_buffer/editor_buffer_struct.rs index d54e08e2c..98795b73c 100644 --- a/tui/src/tui/editor/editor_buffer/editor_buffer_struct.rs +++ b/tui/src/tui/editor/editor_buffer/editor_buffer_struct.rs @@ -760,6 +760,7 @@ pub mod access_and_mutate { } } +// 00: [ ] clean this up & make it compat with CustomEventFormatter pub mod debug_format_helpers { use super::*; diff --git a/tuify/Cargo.toml b/tuify/Cargo.toml index 5d058e18f..e03213381 100644 --- a/tuify/Cargo.toml +++ b/tuify/Cargo.toml @@ -30,8 +30,9 @@ path = "src/bin/rt.rs" [dependencies] # r3bl-open-core. -r3bl_core = { path = "../core", version = "0.10.0" } # version is requried to publish to crates.io -r3bl_ansi_color = { path = "../ansi_color", version = "0.7.0" } # version is requried to publish to crates.io +r3bl_core = { path = "../core", version = "0.10.0" } # version is required to publish to crates.io +r3bl_ansi_color = { path = "../ansi_color", version = "0.7.0" } # version is required to publish to crates.io +r3bl_log = { path = "../log", version = "0.1.0" } # version is required to publish to crates.io # serde for JSON serialization. serde = { version = "1.0.210", features = ["derive"] } diff --git a/tuify/examples/main_interactive.rs b/tuify/examples/main_interactive.rs index ff5e9f982..6a8d23be2 100644 --- a/tuify/examples/main_interactive.rs +++ b/tuify/examples/main_interactive.rs @@ -18,11 +18,8 @@ use std::{io::Result, vec}; use r3bl_ansi_color::{AnsiStyledText, Color, Style as RStyle}; -use r3bl_core::{call_if_true, - get_size, - get_terminal_width, - throws, - try_initialize_global_logging}; +use r3bl_core::{call_if_true, get_size, get_terminal_width, throws}; +use r3bl_log::try_initialize_logging_global; use r3bl_tuify::{components::style::StyleSheet, select_from_list, select_from_list_with_multi_line_header, @@ -34,7 +31,7 @@ use single_select_quiz_game::main as single_select_quiz_game; fn main() -> Result<()> { throws!({ call_if_true!(DEVELOPMENT_MODE, { - try_initialize_global_logging(tracing_core::LevelFilter::DEBUG).ok(); + try_initialize_logging_global(tracing_core::LevelFilter::DEBUG).ok(); tracing::debug!("Start logging... terminal window size: {:?}", get_size()?) }); diff --git a/tuify/src/bin/rt.rs b/tuify/src/bin/rt.rs index 9bd2890e6..58977a692 100644 --- a/tuify/src/bin/rt.rs +++ b/tuify/src/bin/rt.rs @@ -27,11 +27,8 @@ use r3bl_ansi_color::{is_stdin_piped, is_stdout_piped, StdinIsPipedResult, StdoutIsPipedResult}; -use r3bl_core::{call_if_true, - get_size, - get_terminal_width, - throws, - try_initialize_global_logging}; +use r3bl_core::{call_if_true, get_size, get_terminal_width, throws}; +use r3bl_log::try_initialize_logging_global; use r3bl_tuify::{select_from_list, SelectionMode, StyleSheet, DEVELOPMENT_MODE}; use reedline::{DefaultPrompt, DefaultPromptSegment, Reedline, Signal}; use StdinIsPipedResult::{StdinIsNotPiped, StdinIsPiped}; @@ -99,7 +96,7 @@ fn main() -> Result<()> { let enable_logging = DEVELOPMENT_MODE | cli_args.global_opts.enable_logging; call_if_true!(enable_logging, { - try_initialize_global_logging(tracing_core::LevelFilter::DEBUG).ok(); + try_initialize_logging_global(tracing_core::LevelFilter::DEBUG).ok(); tracing::debug!("Start logging... terminal window size: {:?}", get_size()?); tracing::debug!("cli_args {cli_args:?}") }); diff --git a/tuify/src/test_utils.rs b/tuify/src/test_utils.rs index 08eabc98d..702a5799b 100644 --- a/tuify/src/test_utils.rs +++ b/tuify/src/test_utils.rs @@ -79,39 +79,3 @@ impl KeyPressReader for TestVecKeyPressReader { self.key_press_vec[index] } } - -pub fn contains_ansi_escape_sequence(text: &str) -> bool { - text.chars().any(|it| it == '\x1b') -} - -#[test] -fn test_is_ansi_escape_sequence() { - use r3bl_ansi_color::{AnsiStyledText, Color, Style}; - use r3bl_core::assert_eq2; - - assert_eq2!( - contains_ansi_escape_sequence( - "\x1b[31mThis is red text.\x1b[0m And this is normal text." - ), - true - ); - - assert_eq2!(contains_ansi_escape_sequence("This is normal text."), false); - - assert_eq2!( - contains_ansi_escape_sequence( - &AnsiStyledText { - text: "Print a formatted (bold, italic, underline) string w/ ANSI color codes.", - style: &[ - Style::Bold, - Style::Italic, - Style::Underline, - Style::Foreground(Color::Rgb(50, 50, 50)), - Style::Background(Color::Rgb(100, 200, 1)), - ], - } - .to_string() - ), - true - ); -}