From 5ad5cd39edf240c0016516811db184b3374ee425 Mon Sep 17 00:00:00 2001 From: "Christoph J. Scherr" Date: Thu, 27 Jun 2024 16:45:28 +0200 Subject: [PATCH] ultra fancy cli for diesel-demo --- Cargo.lock | 107 +++++++++++++++++++++++++ members/diesel-demo/Cargo.toml | 4 + members/diesel-demo/src/cli.rs | 128 ++++++++++++++++++++++++++++++ members/diesel-demo/src/lib.rs | 44 +--------- members/diesel-demo/src/main.rs | 28 ++----- members/diesel-demo/src/models.rs | 22 ++--- 6 files changed, 257 insertions(+), 76 deletions(-) create mode 100644 members/diesel-demo/src/cli.rs diff --git a/Cargo.lock b/Cargo.lock index b7ccc07..9e36606 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -358,6 +358,19 @@ dependencies = [ "windows-sys 0.48.0", ] +[[package]] +name = "comfy-table" +version = "7.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b34115915337defe99b2aff5c2ce6771e5fbc4079f4b506301f5cf394c8452f7" +dependencies = [ + "console", + "crossterm", + "strum", + "strum_macros", + "unicode-width", +] + [[package]] name = "console" version = "0.15.8" @@ -463,6 +476,28 @@ version = "0.8.20" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "22ec99545bb0ed0ea7bb9b8e1e9122ea386ff8a48c0922e43f36d45ab09e0e80" +[[package]] +name = "crossterm" +version = "0.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f476fe445d41c9e991fd07515a6f463074b782242ccf4a5b7b1d1012e70824df" +dependencies = [ + "bitflags 2.6.0", + "crossterm_winapi", + "libc", + "parking_lot", + "winapi", +] + +[[package]] +name = "crossterm_winapi" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "acdd7c62a3665c7f6830a51635d9ac9b23ed385797f70a83bb8bafe9c572ab2b" +dependencies = [ + "winapi", +] + [[package]] name = "crunchy" version = "0.2.2" @@ -610,6 +645,19 @@ dependencies = [ "syn", ] +[[package]] +name = "dialoguer" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "658bce805d770f407bc62102fca7c2c64ceef2fbcb2b8bd19d2765ce093980de" +dependencies = [ + "console", + "shell-words", + "tempfile", + "thiserror", + "zeroize", +] + [[package]] name = "diesel" version = "2.2.1" @@ -629,8 +677,12 @@ version = "0.1.0" dependencies = [ "anyhow", "colored", + "comfy-table", + "console", + "dialoguer", "diesel", "dotenvy", + "indicatif", "libpt", "serde", "serde_json", @@ -759,6 +811,12 @@ version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7360491ce676a36bf9bb3c56c1aa791658183a54d2744120f27285738d90465a" +[[package]] +name = "fastrand" +version = "2.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9fc0510504f03c51ada170672ac806f1f105a88aa97a5281117e1ddc3368e51a" + [[package]] name = "fluent" version = "0.16.1" @@ -2035,6 +2093,12 @@ dependencies = [ "windows-sys 0.52.0", ] +[[package]] +name = "rustversion" +version = "1.0.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "955d28af4278de8121b7ebeb796b6a45735dc01436d898801014aced2773a3d6" + [[package]] name = "ryu" version = "1.0.18" @@ -2185,6 +2249,12 @@ dependencies = [ "lazy_static", ] +[[package]] +name = "shell-words" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24188a676b6ae68c3b2cb3a01be17fbf7240ce009799bb56d5b1409051e78fde" + [[package]] name = "shortc" version = "0.1.0" @@ -2275,6 +2345,25 @@ version = "0.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" +[[package]] +name = "strum" +version = "0.26.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fec0f0aef304996cf250b31b5a10dee7980c85da9d759361292b8bca5a18f06" + +[[package]] +name = "strum_macros" +version = "0.26.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c6bee85a5a24955dc440386795aa378cd9cf82acd5f764469152d2270e581be" +dependencies = [ + "heck 0.5.0", + "proc-macro2", + "quote", + "rustversion", + "syn", +] + [[package]] name = "subtle" version = "2.6.1" @@ -2325,6 +2414,18 @@ dependencies = [ "syn", ] +[[package]] +name = "tempfile" +version = "3.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85b77fafb263dd9d05cbeac119526425676db3784113aa9295c88498cbf8bff1" +dependencies = [ + "cfg-if", + "fastrand", + "rustix", + "windows-sys 0.52.0", +] + [[package]] name = "terminal_size" version = "0.3.0" @@ -3078,3 +3179,9 @@ dependencies = [ "quote", "syn", ] + +[[package]] +name = "zeroize" +version = "1.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ced3678a2879b30306d323f4542626697a464a97c0a07c9aebf7ebca65cd4dde" diff --git a/members/diesel-demo/Cargo.toml b/members/diesel-demo/Cargo.toml index 9e896ec..a1f7ac1 100644 --- a/members/diesel-demo/Cargo.toml +++ b/members/diesel-demo/Cargo.toml @@ -6,8 +6,12 @@ edition = "2021" [dependencies] anyhow.workspace = true colored = "2.1.0" +comfy-table = { version = "7.1.1", features = ["console"] } +console = "0.15.8" +dialoguer = { version = "0.11.0", features = ["completion", "history"] } diesel = { version = "2.2.1", features = ["serde_json", "sqlite", "uuid", "returning_clauses_for_sqlite_3_35"] } dotenvy = "0.15.7" +indicatif = "0.17.8" libpt.workspace = true serde = { workspace = true, features = ["serde_derive"] } serde_json.workspace = true diff --git a/members/diesel-demo/src/cli.rs b/members/diesel-demo/src/cli.rs new file mode 100644 index 0000000..af5640a --- /dev/null +++ b/members/diesel-demo/src/cli.rs @@ -0,0 +1,128 @@ +use std::fmt::Display; + +use comfy_table::presets::UTF8_FULL_CONDENSED; +use comfy_table::Table; +use console::{style, Color}; +use dialoguer::{theme::ColorfulTheme, Completion, Input}; +use dialoguer::{BasicHistory, History}; +use libpt::log::{info, warn}; + +use crate::models::{self, Post}; + +const HELP_TEXT: &str = "\ + help|? - show this menu\n\ + exit - exit the application\n\ + list|ls - list all posts\n\ + publish [id] - publish the post with the id [id]\n\ + unpublish [id] - make the post with the id [id] a draft\n\ + delete [id] - delete the post with the id [id]\n\ + read|show [id] - display the post with the id [id]\n\ + new - create a new post"; +const USAGE_TEXT: &str = "Bad input: try 'help'"; + +pub struct MyCompletion { + options: Vec, +} +impl Default for MyCompletion { + fn default() -> Self { + MyCompletion { + options: vec![ + "help".to_string(), + "?".to_string(), + "list".to_string(), + "publish".to_string(), + "unpublish".to_string(), + "delete".to_string(), + "read".to_string(), + "show".to_string(), + "new".to_string(), + "ls".to_string(), + ], + } + } +} + +impl Completion for MyCompletion { + /// Simple completion implementation based on substring + fn get(&self, input: &str) -> Option { + let matches = self + .options + .iter() + .filter(|option| option.starts_with(input)) + .collect::>(); + + if matches.len() == 1 { + Some(matches[0].to_string()) + } else { + None + } + } +} +pub fn table_posts(posts_to_print: &Vec) { + let mut table = Table::new(); + table + .load_preset(UTF8_FULL_CONDENSED) + .set_content_arrangement(comfy_table::ContentArrangement::DynamicFullWidth) + .set_header(vec!["id", "title", "published?", "body"]); + for post in posts_to_print { + let mut stitle = post.title.clone(); + stitle.truncate(40); + let mut sbody = post.body.clone(); + sbody.truncate(40); + + table.add_row(vec![ + post.id.to_string(), + stitle, + post.published.to_string(), + sbody, + ]); + } + println!("{}", style(table).dim()); +} + +impl Display for Post { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + let mut table = Table::new(); + table + .load_preset(UTF8_FULL_CONDENSED) + .set_content_arrangement(comfy_table::ContentArrangement::DynamicFullWidth) + .set_header(vec![format!("{:<6} {}", self.id, self.title)]) + .add_row(vec![self.body.clone()]); + writeln!(f, "{table}") + } +} + +pub fn read_buf_interactive( + buf: &mut String, + completion: &impl Completion, + history: &mut BasicHistory, +) -> anyhow::Result<()> { + buf.clear(); + + *buf = Input::with_theme(&ColorfulTheme::default()) + .completion_with(completion) + .history_with(history) + .interact_text()?; + + Ok(()) +} + +pub fn usage() { + borderprint(USAGE_TEXT, Some(Color::Red)); +} + +pub fn help() { + borderprint(HELP_TEXT, Some(Color::Cyan)); +} + +pub(crate) fn borderprint(content: impl ToString, color: Option) { + let mut table = Table::new(); + table + .load_preset(UTF8_FULL_CONDENSED) + .set_content_arrangement(comfy_table::ContentArrangement::DynamicFullWidth) + .add_row(vec![content.to_string()]); + match color { + Some(c) => println!("{}", style(table).fg(c)), + None => println!("{table}") + } +} diff --git a/members/diesel-demo/src/lib.rs b/members/diesel-demo/src/lib.rs index 66fb2c9..b71f838 100644 --- a/members/diesel-demo/src/lib.rs +++ b/members/diesel-demo/src/lib.rs @@ -1,5 +1,6 @@ pub mod models; pub mod schema; +pub mod cli; use self::schema::posts::dsl::*; @@ -7,6 +8,7 @@ use std::io::Write; use std::{env, io}; use colored::Colorize; +use dialoguer::Input; use diesel::prelude::*; use diesel::sqlite::SqliteConnection; @@ -34,45 +36,3 @@ pub fn load_relevant_posts(conn: &mut SqliteConnection) -> anyhow::Result) { - if !posts_to_print.is_empty() { - println!( - "{: <12}| {: <30} | {: <40}[...] | {: <12} | {: <5}", - "id", "title", "body (truncated)", "body len", "is published?" - ); - println!("{:=^140}", ""); - for post in posts_to_print { - let mut short_title = post.body.clone(); - short_title.truncate(30); - let mut short_body = post.body.clone(); - short_body.truncate(40); - println!( - "{: <12}| {: <30} | {: <40}[...] | {: <12} | {: <5}", - post.id, - short_title, - short_body, - post.body.len(), - post.published - ); - } - info!("total: {}", posts_to_print.len()); - } else { - warn!("Tried to display posts, but there are no posts stored in the database"); - } -} - -// NOTE: this can't handle unicode stuff like 春 and I don't really care -pub fn read_buf_interactive(buf: &mut String) -> anyhow::Result<()> { - buf.clear(); - let stdin = io::stdin(); - let mut stdout = io::stdout(); - - print!("{}", "> ".green().bold()); - stdout.flush()?; - stdin.read_line(buf)?; - *buf = buf.trim().to_string(); - - Ok(()) -} diff --git a/members/diesel-demo/src/main.rs b/members/diesel-demo/src/main.rs index 7df25a4..e55d3e1 100644 --- a/members/diesel-demo/src/main.rs +++ b/members/diesel-demo/src/main.rs @@ -1,19 +1,9 @@ +use dialoguer::BasicHistory; use diesel::SqliteConnection; use diesel_demo::models::{Post, PostDraft}; use libpt::log::{self, debug, error, trace, warn}; -const HELP_TEXT: &str = "\ - help|? - show this menu\n\ - exit - exit the application\n\ - list - list all posts\n\ - publish [id] - publish the post with the id [id]\n\ - unpublish [id] - make the post with the id [id] a draft\n\ - delete [id] - delete the post with the id [id]\n\ - read|show [id] - display the post with the id [id]\n\ - new - create a new post"; -const USAGE_TEXT: &str = "Bad input: try 'help'"; - -use colored::*; +use lib::cli::*; use diesel_demo as lib; @@ -37,12 +27,14 @@ fn main() -> anyhow::Result<()> { fn repl(conn: &mut SqliteConnection) -> anyhow::Result<()> { let mut buf = String::new(); + let completion = MyCompletion::default(); + let mut history = BasicHistory::new(); loop { - lib::read_buf_interactive(&mut buf)?; + read_buf_interactive(&mut buf, &completion, &mut history)?; buf = buf.to_uppercase(); if buf.starts_with("HELP") || buf.starts_with('?') { - println!("{}", HELP_TEXT.bright_blue()) + help() } else if buf.starts_with("EXIT") || buf.is_empty() { break; } else if buf.starts_with("UNPUBLISH") { @@ -101,10 +93,10 @@ fn repl(conn: &mut SqliteConnection) -> anyhow::Result<()> { } } }; - } else if buf.starts_with("LIST") { + } else if buf.starts_with("LIST") || buf.starts_with("LS") { let posts = lib::load_all_posts(conn)?; trace!("loaded posts for display: {posts:#?}"); - lib::print_posts(&posts); + table_posts(&posts); } else if buf.starts_with("NEW") { let post = PostDraft::interactive_create()?; let _ = post.post(conn).inspect_err(|e| { @@ -118,10 +110,6 @@ fn repl(conn: &mut SqliteConnection) -> anyhow::Result<()> { Ok(()) } -fn usage() { - println!("{}", USAGE_TEXT.red().bold()); -} - fn get_id(buf: &str) -> Option { match buf.split(' ').nth(1) { Some(s) => match s.parse() { diff --git a/members/diesel-demo/src/models.rs b/members/diesel-demo/src/models.rs index efcb0f1..04aa9b2 100644 --- a/members/diesel-demo/src/models.rs +++ b/members/diesel-demo/src/models.rs @@ -1,6 +1,7 @@ use std::fmt::Display; use std::io::{self, Read, Write}; +use dialoguer::Confirm; use diesel::prelude::*; use libpt::log::{info, trace}; @@ -40,6 +41,13 @@ impl Post { Ok(()) } pub fn delete(conn: &mut SqliteConnection, id: i32) -> anyhow::Result<()> { + let confirmation = Confirm::new() + .with_prompt(format!("You are about to delete post {id}, continue?")) + .interact()?; + if !confirmation { + return Ok(()); + } + use crate::schema::posts::dsl::posts; let post = diesel::delete(posts.find(id)) @@ -50,20 +58,6 @@ impl Post { } } -impl Display for Post { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - if !self.published { - writeln!(f, "this post has not yet been published!") - } else { - writeln!( - f, - "\n{:<60} | published: {:<5}\n{:=^140}\n\n{}", - self.title, self.published, "", self.body - ) - } - } -} - #[derive(Insertable, Debug)] #[diesel(table_name = crate::schema::posts)] #[diesel(check_for_backend(diesel::sqlite::Sqlite))] // optional but improves generated compiler errors