ultra fancy cli for diesel-demo
cargo devel CI / cargo CI (push) Successful in 3m25s Details

This commit is contained in:
Christoph J. Scherr 2024-06-27 16:45:28 +02:00
parent c84672cf78
commit 5ad5cd39ed
6 changed files with 257 additions and 76 deletions

107
Cargo.lock generated
View File

@ -358,6 +358,19 @@ dependencies = [
"windows-sys 0.48.0", "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]] [[package]]
name = "console" name = "console"
version = "0.15.8" version = "0.15.8"
@ -463,6 +476,28 @@ version = "0.8.20"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "22ec99545bb0ed0ea7bb9b8e1e9122ea386ff8a48c0922e43f36d45ab09e0e80" 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]] [[package]]
name = "crunchy" name = "crunchy"
version = "0.2.2" version = "0.2.2"
@ -610,6 +645,19 @@ dependencies = [
"syn", "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]] [[package]]
name = "diesel" name = "diesel"
version = "2.2.1" version = "2.2.1"
@ -629,8 +677,12 @@ version = "0.1.0"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"colored", "colored",
"comfy-table",
"console",
"dialoguer",
"diesel", "diesel",
"dotenvy", "dotenvy",
"indicatif",
"libpt", "libpt",
"serde", "serde",
"serde_json", "serde_json",
@ -759,6 +811,12 @@ version = "0.1.9"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7360491ce676a36bf9bb3c56c1aa791658183a54d2744120f27285738d90465a" checksum = "7360491ce676a36bf9bb3c56c1aa791658183a54d2744120f27285738d90465a"
[[package]]
name = "fastrand"
version = "2.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9fc0510504f03c51ada170672ac806f1f105a88aa97a5281117e1ddc3368e51a"
[[package]] [[package]]
name = "fluent" name = "fluent"
version = "0.16.1" version = "0.16.1"
@ -2035,6 +2093,12 @@ dependencies = [
"windows-sys 0.52.0", "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]] [[package]]
name = "ryu" name = "ryu"
version = "1.0.18" version = "1.0.18"
@ -2185,6 +2249,12 @@ dependencies = [
"lazy_static", "lazy_static",
] ]
[[package]]
name = "shell-words"
version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "24188a676b6ae68c3b2cb3a01be17fbf7240ce009799bb56d5b1409051e78fde"
[[package]] [[package]]
name = "shortc" name = "shortc"
version = "0.1.0" version = "0.1.0"
@ -2275,6 +2345,25 @@ version = "0.11.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" 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]] [[package]]
name = "subtle" name = "subtle"
version = "2.6.1" version = "2.6.1"
@ -2325,6 +2414,18 @@ dependencies = [
"syn", "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]] [[package]]
name = "terminal_size" name = "terminal_size"
version = "0.3.0" version = "0.3.0"
@ -3078,3 +3179,9 @@ dependencies = [
"quote", "quote",
"syn", "syn",
] ]
[[package]]
name = "zeroize"
version = "1.8.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ced3678a2879b30306d323f4542626697a464a97c0a07c9aebf7ebca65cd4dde"

View File

@ -6,8 +6,12 @@ edition = "2021"
[dependencies] [dependencies]
anyhow.workspace = true anyhow.workspace = true
colored = "2.1.0" 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"] } diesel = { version = "2.2.1", features = ["serde_json", "sqlite", "uuid", "returning_clauses_for_sqlite_3_35"] }
dotenvy = "0.15.7" dotenvy = "0.15.7"
indicatif = "0.17.8"
libpt.workspace = true libpt.workspace = true
serde = { workspace = true, features = ["serde_derive"] } serde = { workspace = true, features = ["serde_derive"] }
serde_json.workspace = true serde_json.workspace = true

View File

@ -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<String>,
}
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<String> {
let matches = self
.options
.iter()
.filter(|option| option.starts_with(input))
.collect::<Vec<_>>();
if matches.len() == 1 {
Some(matches[0].to_string())
} else {
None
}
}
}
pub fn table_posts(posts_to_print: &Vec<models::Post>) {
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<Color>) {
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}")
}
}

View File

@ -1,5 +1,6 @@
pub mod models; pub mod models;
pub mod schema; pub mod schema;
pub mod cli;
use self::schema::posts::dsl::*; use self::schema::posts::dsl::*;
@ -7,6 +8,7 @@ use std::io::Write;
use std::{env, io}; use std::{env, io};
use colored::Colorize; use colored::Colorize;
use dialoguer::Input;
use diesel::prelude::*; use diesel::prelude::*;
use diesel::sqlite::SqliteConnection; use diesel::sqlite::SqliteConnection;
@ -34,45 +36,3 @@ pub fn load_relevant_posts(conn: &mut SqliteConnection) -> anyhow::Result<Vec<mo
.load(conn)?) .load(conn)?)
} }
// NOTE: formatting breaks when you use japanese fullwidth (or probably other longer chars too)
// characters. Works well for the regular alphabet
pub fn print_posts(posts_to_print: &Vec<models::Post>) {
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(())
}

View File

@ -1,19 +1,9 @@
use dialoguer::BasicHistory;
use diesel::SqliteConnection; use diesel::SqliteConnection;
use diesel_demo::models::{Post, PostDraft}; use diesel_demo::models::{Post, PostDraft};
use libpt::log::{self, debug, error, trace, warn}; use libpt::log::{self, debug, error, trace, warn};
const HELP_TEXT: &str = "\ use lib::cli::*;
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 diesel_demo as lib; use diesel_demo as lib;
@ -37,12 +27,14 @@ fn main() -> anyhow::Result<()> {
fn repl(conn: &mut SqliteConnection) -> anyhow::Result<()> { fn repl(conn: &mut SqliteConnection) -> anyhow::Result<()> {
let mut buf = String::new(); let mut buf = String::new();
let completion = MyCompletion::default();
let mut history = BasicHistory::new();
loop { loop {
lib::read_buf_interactive(&mut buf)?; read_buf_interactive(&mut buf, &completion, &mut history)?;
buf = buf.to_uppercase(); buf = buf.to_uppercase();
if buf.starts_with("HELP") || buf.starts_with('?') { if buf.starts_with("HELP") || buf.starts_with('?') {
println!("{}", HELP_TEXT.bright_blue()) help()
} else if buf.starts_with("EXIT") || buf.is_empty() { } else if buf.starts_with("EXIT") || buf.is_empty() {
break; break;
} else if buf.starts_with("UNPUBLISH") { } 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)?; let posts = lib::load_all_posts(conn)?;
trace!("loaded posts for display: {posts:#?}"); trace!("loaded posts for display: {posts:#?}");
lib::print_posts(&posts); table_posts(&posts);
} else if buf.starts_with("NEW") { } else if buf.starts_with("NEW") {
let post = PostDraft::interactive_create()?; let post = PostDraft::interactive_create()?;
let _ = post.post(conn).inspect_err(|e| { let _ = post.post(conn).inspect_err(|e| {
@ -118,10 +110,6 @@ fn repl(conn: &mut SqliteConnection) -> anyhow::Result<()> {
Ok(()) Ok(())
} }
fn usage() {
println!("{}", USAGE_TEXT.red().bold());
}
fn get_id(buf: &str) -> Option<i32> { fn get_id(buf: &str) -> Option<i32> {
match buf.split(' ').nth(1) { match buf.split(' ').nth(1) {
Some(s) => match s.parse() { Some(s) => match s.parse() {

View File

@ -1,6 +1,7 @@
use std::fmt::Display; use std::fmt::Display;
use std::io::{self, Read, Write}; use std::io::{self, Read, Write};
use dialoguer::Confirm;
use diesel::prelude::*; use diesel::prelude::*;
use libpt::log::{info, trace}; use libpt::log::{info, trace};
@ -40,6 +41,13 @@ impl Post {
Ok(()) Ok(())
} }
pub fn delete(conn: &mut SqliteConnection, id: i32) -> anyhow::Result<()> { 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; use crate::schema::posts::dsl::posts;
let post = diesel::delete(posts.find(id)) 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)] #[derive(Insertable, Debug)]
#[diesel(table_name = crate::schema::posts)] #[diesel(table_name = crate::schema::posts)]
#[diesel(check_for_backend(diesel::sqlite::Sqlite))] // optional but improves generated compiler errors #[diesel(check_for_backend(diesel::sqlite::Sqlite))] // optional but improves generated compiler errors