From e4fcdaaed4c27d270fec6f08758cff090f2fa93b Mon Sep 17 00:00:00 2001 From: Andrew Lilley Brinker Date: Fri, 1 Mar 2024 13:24:39 -0800 Subject: [PATCH] chore: Split up CLI crate into modules. (#135) Previously, all the logic of the CLI crate was together in a single file, but this was getting increasingly hard to manage. This commit breaks up that CLI into a bunch of separate modules. It also mildly refactors the print queue logic to be behind a Printer type, which simplifies use a little bit. Signed-off-by: Andrew Lilley Brinker --- omnibor/src/bin/omnibor.rs | 438 ------------------------------- omnibor/src/bin/omnibor/cli.rs | 128 +++++++++ omnibor/src/bin/omnibor/find.rs | 47 ++++ omnibor/src/bin/omnibor/fs.rs | 98 +++++++ omnibor/src/bin/omnibor/id.rs | 18 ++ omnibor/src/bin/omnibor/main.rs | 40 +++ omnibor/src/bin/omnibor/print.rs | 199 ++++++++++++++ 7 files changed, 530 insertions(+), 438 deletions(-) delete mode 100644 omnibor/src/bin/omnibor.rs create mode 100644 omnibor/src/bin/omnibor/cli.rs create mode 100644 omnibor/src/bin/omnibor/find.rs create mode 100644 omnibor/src/bin/omnibor/fs.rs create mode 100644 omnibor/src/bin/omnibor/id.rs create mode 100644 omnibor/src/bin/omnibor/main.rs create mode 100644 omnibor/src/bin/omnibor/print.rs diff --git a/omnibor/src/bin/omnibor.rs b/omnibor/src/bin/omnibor.rs deleted file mode 100644 index 6453adc..0000000 --- a/omnibor/src/bin/omnibor.rs +++ /dev/null @@ -1,438 +0,0 @@ -use anyhow::anyhow; -use anyhow::Context as _; -use anyhow::Error; -use anyhow::Result; -use async_walkdir::DirEntry as AsyncDirEntry; -use async_walkdir::WalkDir; -use clap::Args; -use clap::Parser; -use clap::Subcommand; -use futures_lite::stream::StreamExt as _; -use omnibor::ArtifactId; -use omnibor::Sha256; -use serde_json::json; -use serde_json::Value as JsonValue; -use smart_default::SmartDefault; -use std::default::Default; -use std::fmt::Display; -use std::fmt::Formatter; -use std::fmt::Result as FmtResult; -use std::path::Path; -use std::path::PathBuf; -use std::process::ExitCode; -use std::str::FromStr; -use std::sync::atomic::AtomicBool; -use std::sync::Arc; -use std::time::Duration; -use tokio::fs::File as AsyncFile; -use tokio::io::AsyncWrite; -use tokio::io::AsyncWriteExt as _; -use tokio::sync::mpsc; -use tokio::sync::mpsc::Sender; -use tokio::time::sleep; -use url::Url; - -#[tokio::main] -async fn main() -> ExitCode { - let args = Cli::parse(); - - let printing_done = Arc::new(AtomicBool::new(false)); - let printing_done_2 = printing_done.clone(); - - // TODO(alilleybrinker): Make this channel Msg limit configurable. - let (tx, mut rx) = mpsc::channel::(args.buffer.unwrap_or(100)); - - // Do all printing in a separate task we spawn to _just_ do printing. - // This stops printing from blocking the worker tasks. - tokio::spawn(async move { - while let Some(msg) = rx.recv().await { - match msg { - MsgOrEnd::End => break, - MsgOrEnd::Message(msg) => { - // TODO(alilleybrinker): Handle this error. - let _ = msg.print().await; - } - } - } - rx.close(); - printing_done_2.store(true, std::sync::atomic::Ordering::Relaxed); - }); - - let result = match args.command { - Command::Id(ref args) => run_id(&tx, args).await, - Command::Find(ref args) => run_find(&tx, args).await, - }; - - let mut return_code = ExitCode::SUCCESS; - - if let Err(e) = result { - // TODO(alilleybrinker): Handle this erroring out, probably by - // sync-printing as a last resort. - let _ = tx.send(MsgOrEnd::error(e, args.format())).await; - return_code = ExitCode::FAILURE; - } - - // send a message to end the printing - tx.send(MsgOrEnd::End).await.unwrap(); - - // wait until the printing is done - while !printing_done.load(std::sync::atomic::Ordering::Relaxed) { - sleep(Duration::from_millis(10)).await; - } - - return_code -} - -/*=========================================================================== - * CLI Arguments - *-------------------------------------------------------------------------*/ - -#[derive(Debug, Parser)] -#[command(version)] -struct Cli { - #[command(subcommand)] - command: Command, - - /// How many print messages to buffer at one time, tunes printing perf - #[arg(short = 'b', long = "buffer")] - buffer: Option, -} - -impl Cli { - fn format(&self) -> Format { - match &self.command { - Command::Id(args) => args.format, - Command::Find(args) => args.format, - } - } -} - -#[derive(Debug, Subcommand)] -enum Command { - /// For files, prints their Artifact ID. For directories, recursively prints IDs for all files under it. - Id(IdArgs), - - /// Find file matching an Artifact ID. - Find(FindArgs), -} - -#[derive(Debug, Args)] -struct IdArgs { - /// Path to identify - path: PathBuf, - - /// Output format (can be "plain", "short", or "json") - #[arg(short = 'f', long = "format", default_value_t)] - format: Format, - - /// Hash algorithm (can be "sha256") - #[arg(short = 'H', long = "hash", default_value_t)] - hash: SelectedHash, -} - -#[derive(Debug, Args)] -struct FindArgs { - /// `gitoid` URL to match - url: Url, - - /// The root path to search under - path: PathBuf, - - /// Output format (can be "plain", "short", or "json") - #[arg(short = 'f', long = "format", default_value_t)] - format: Format, -} - -#[derive(Debug, Copy, Clone, PartialEq, Eq, SmartDefault)] -enum Format { - #[default] - Plain, - Json, - Short, -} - -impl Display for Format { - fn fmt(&self, f: &mut Formatter<'_>) -> FmtResult { - match self { - Format::Plain => write!(f, "plain"), - Format::Json => write!(f, "json"), - Format::Short => write!(f, "short"), - } - } -} - -impl FromStr for Format { - type Err = Error; - - fn from_str(s: &str) -> Result { - match s { - "plain" => Ok(Format::Plain), - "json" => Ok(Format::Json), - "short" => Ok(Format::Short), - _ => Err(anyhow!("unknown format '{}'", s)), - } - } -} - -#[derive(Debug, Copy, Clone, PartialEq, Eq, SmartDefault)] -enum SelectedHash { - #[default] - Sha256, -} - -impl Display for SelectedHash { - fn fmt(&self, f: &mut Formatter<'_>) -> FmtResult { - match self { - SelectedHash::Sha256 => write!(f, "sha256"), - } - } -} - -impl FromStr for SelectedHash { - type Err = Error; - - fn from_str(s: &str) -> Result { - match s { - "sha256" => Ok(SelectedHash::Sha256), - _ => Err(anyhow!("unknown hash algorithm '{}'", s)), - } - } -} - -#[derive(Debug, Clone)] -enum MsgOrEnd { - End, - Message(Msg), -} - -impl MsgOrEnd { - fn id(path: &Path, url: &Url, format: Format) -> Self { - MsgOrEnd::Message(Msg::id(path, url, format)) - } - - fn error>(error: E, format: Format) -> MsgOrEnd { - MsgOrEnd::Message(Msg::error(error, format)) - } -} - -#[derive(Debug, Clone)] -struct Msg { - content: Content, - status: Status, -} - -impl Msg { - fn id(path: &Path, url: &Url, format: Format) -> Self { - let status = Status::Success; - let path = path.display().to_string(); - let url = url.to_string(); - - match format { - Format::Plain => Msg::plain(status, &format!("{} => {}", path, url)), - Format::Short => Msg::plain(status, &format!("{}", url)), - Format::Json => Msg::json(status, json!({ "path": path, "id": url })), - } - } - - fn error>(error: E, format: Format) -> Msg { - fn _error(error: Error, format: Format) -> Msg { - let status = Status::Error; - - match format { - Format::Plain | Format::Short => { - Msg::plain(status, &format!("error: {}", error.to_string())) - } - Format::Json => Msg::json(status, json!({"error": error.to_string()})), - } - } - - _error(error.into(), format) - } - - /// Construct a new plain Msg. - fn plain(status: Status, s: &str) -> Self { - Msg { - content: Content::Plain(s.to_string()), - status, - } - } - - /// Construct a new JSON Msg. - fn json(status: Status, j: JsonValue) -> Self { - Msg { - content: Content::Json(j), - status, - } - } - - /// Print the Msg to the appropriate sink. - async fn print(self) -> Result<()> { - let to_output = self.content.to_string(); - self.resolve_sink().write_all(to_output.as_bytes()).await?; - Ok(()) - } - - /// Get the sink associated with the type of Msg. - fn resolve_sink(&self) -> Box { - match self.status { - Status::Success => Box::new(tokio::io::stdout()), - Status::Error => Box::new(tokio::io::stderr()), - } - } -} - -#[derive(Debug, Clone)] -enum Content { - Json(JsonValue), - Plain(String), -} - -impl Display for Content { - fn fmt(&self, f: &mut Formatter<'_>) -> FmtResult { - match self { - Content::Plain(s) => write!(f, "{}\n", s), - Content::Json(j) => write!(f, "{}\n", j), - } - } -} - -#[derive(Debug, Clone, Copy)] -enum Status { - Success, - Error, -} - -/*=========================================================================== - * Command Implementations - *-------------------------------------------------------------------------*/ - -/// Run the `id` subcommand. -async fn run_id(tx: &Sender, args: &IdArgs) -> Result<()> { - let mut file = open_async_file(&args.path).await?; - - if file_is_dir(&file).await? { - id_directory(tx, &args.path, args.format, args.hash).await - } else { - id_file(tx, &mut file, &args.path, args.format, args.hash).await - } -} - -/// Run the `find` subcommand. -async fn run_find(tx: &Sender, args: &FindArgs) -> Result<()> { - let FindArgs { url, path, format } = args; - - let id = ArtifactId::::id_url(url.clone())?; - let url = id.url(); - - let mut entries = WalkDir::new(&path); - - loop { - match entries.next().await { - None => break, - Some(Err(e)) => tx.send(MsgOrEnd::Message(Msg::error(e, *format))).await?, - Some(Ok(entry)) => { - let path = &entry.path(); - - if entry_is_dir(&entry).await? { - continue; - } - - let mut file = open_async_file(&path).await?; - let file_url = hash_file(SelectedHash::Sha256, &mut file, &path).await?; - - if url == file_url { - tx.send(MsgOrEnd::id(&path, &url, *format)).await?; - return Ok(()); - } - } - } - } - - Ok(()) -} - -/*=========================================================================== - * Helper Functions - *-------------------------------------------------------------------------*/ - -// Identify, recursively, all the files under a directory. -async fn id_directory( - tx: &Sender, - path: &Path, - format: Format, - hash: SelectedHash, -) -> Result<()> { - let mut entries = WalkDir::new(path); - - loop { - match entries.next().await { - None => break, - Some(Err(e)) => tx.send(MsgOrEnd::error(e, format)).await?, - Some(Ok(entry)) => { - let path = &entry.path(); - - if entry_is_dir(&entry).await? { - continue; - } - - let mut file = open_async_file(&path).await?; - id_file(tx, &mut file, &path, format, hash).await?; - } - } - } - - Ok(()) -} - -/// Identify a single file. -async fn id_file( - tx: &Sender, - file: &mut AsyncFile, - path: &Path, - format: Format, - hash: SelectedHash, -) -> Result<()> { - let url = hash_file(hash, file, &path).await?; - tx.send(MsgOrEnd::id(path, &url, format)).await?; - Ok(()) -} - -/// Hash the file and produce a `gitoid`-scheme URL. -async fn hash_file(hash: SelectedHash, file: &mut AsyncFile, path: &Path) -> Result { - match hash { - SelectedHash::Sha256 => sha256_id_async_file(file, &path).await.map(|id| id.url()), - } -} - -/// Check if the file is for a directory. -async fn file_is_dir(file: &AsyncFile) -> Result { - Ok(file.metadata().await.map(|meta| meta.is_dir())?) -} - -/// Check if the entry is for a directory. -async fn entry_is_dir(entry: &AsyncDirEntry) -> Result { - entry - .file_type() - .await - .with_context(|| { - format!( - "unable to identify file type for '{}'", - entry.path().display() - ) - }) - .map(|file_type| file_type.is_dir()) -} - -/// Open an asynchronous file. -async fn open_async_file(path: &Path) -> Result { - AsyncFile::open(path) - .await - .with_context(|| format!("failed to open file '{}'", path.display())) -} - -/// Identify a file using a SHA-256 hash. -async fn sha256_id_async_file(file: &mut AsyncFile, path: &Path) -> Result> { - ArtifactId::id_async_reader(file) - .await - .with_context(|| format!("failed to produce Artifact ID for '{}'", path.display())) -} diff --git a/omnibor/src/bin/omnibor/cli.rs b/omnibor/src/bin/omnibor/cli.rs new file mode 100644 index 0000000..71c010f --- /dev/null +++ b/omnibor/src/bin/omnibor/cli.rs @@ -0,0 +1,128 @@ +//! Defines the Command Line Interface. + +use anyhow::anyhow; +use anyhow::Error; +use anyhow::Result; +use clap::Args; +use clap::Parser; +use clap::Subcommand; +use smart_default::SmartDefault; +use std::default::Default; +use std::fmt::Display; +use std::fmt::Formatter; +use std::fmt::Result as FmtResult; +use std::path::PathBuf; +use std::str::FromStr; +use url::Url; + +#[derive(Debug, Parser)] +#[command(version)] +pub struct Cli { + #[command(subcommand)] + pub command: Command, + + /// How many print messages to buffer at one time, tunes printing perf + #[arg(short = 'b', long = "buffer")] + pub buffer: Option, +} + +impl Cli { + pub fn format(&self) -> Format { + match &self.command { + Command::Id(args) => args.format, + Command::Find(args) => args.format, + } + } +} + +#[derive(Debug, Subcommand)] +pub enum Command { + /// For files, prints their Artifact ID. For directories, recursively prints IDs for all files under it. + Id(IdArgs), + + /// Find file matching an Artifact ID. + Find(FindArgs), +} + +#[derive(Debug, Args)] +pub struct IdArgs { + /// Path to identify + pub path: PathBuf, + + /// Output format (can be "plain", "short", or "json") + #[arg(short = 'f', long = "format", default_value_t)] + pub format: Format, + + /// Hash algorithm (can be "sha256") + #[arg(short = 'H', long = "hash", default_value_t)] + pub hash: SelectedHash, +} + +#[derive(Debug, Args)] +pub struct FindArgs { + /// `gitoid` URL to match + pub url: Url, + + /// The root path to search under + pub path: PathBuf, + + /// Output format (can be "plain", "short", or "json") + #[arg(short = 'f', long = "format", default_value_t)] + pub format: Format, +} + +#[derive(Debug, Copy, Clone, PartialEq, Eq, SmartDefault)] +pub enum Format { + #[default] + Plain, + Json, + Short, +} + +impl Display for Format { + fn fmt(&self, f: &mut Formatter<'_>) -> FmtResult { + match self { + Format::Plain => write!(f, "plain"), + Format::Json => write!(f, "json"), + Format::Short => write!(f, "short"), + } + } +} + +impl FromStr for Format { + type Err = Error; + + fn from_str(s: &str) -> Result { + match s { + "plain" => Ok(Format::Plain), + "json" => Ok(Format::Json), + "short" => Ok(Format::Short), + _ => Err(anyhow!("unknown format '{}'", s)), + } + } +} + +#[derive(Debug, Copy, Clone, PartialEq, Eq, SmartDefault)] +pub enum SelectedHash { + #[default] + Sha256, +} + +impl Display for SelectedHash { + fn fmt(&self, f: &mut Formatter<'_>) -> FmtResult { + match self { + SelectedHash::Sha256 => write!(f, "sha256"), + } + } +} + +impl FromStr for SelectedHash { + type Err = Error; + + fn from_str(s: &str) -> Result { + match s { + "sha256" => Ok(SelectedHash::Sha256), + _ => Err(anyhow!("unknown hash algorithm '{}'", s)), + } + } +} diff --git a/omnibor/src/bin/omnibor/find.rs b/omnibor/src/bin/omnibor/find.rs new file mode 100644 index 0000000..d1045d2 --- /dev/null +++ b/omnibor/src/bin/omnibor/find.rs @@ -0,0 +1,47 @@ +//! The `find` command, which finds files by ID. + +use crate::cli::FindArgs; +use crate::cli::SelectedHash; +use crate::fs::*; +use crate::print::Msg; +use crate::print::PrinterCmd; +use anyhow::Result; +use async_walkdir::WalkDir; +use futures_lite::stream::StreamExt as _; +use omnibor::ArtifactId; +use omnibor::Sha256; +use tokio::sync::mpsc::Sender; + +/// Run the `find` subcommand. +pub async fn run(tx: &Sender, args: &FindArgs) -> Result<()> { + let FindArgs { url, path, format } = args; + + let id = ArtifactId::::id_url(url.clone())?; + let url = id.url(); + + let mut entries = WalkDir::new(&path); + + loop { + match entries.next().await { + None => break, + Some(Err(e)) => tx.send(PrinterCmd::Message(Msg::error(e, *format))).await?, + Some(Ok(entry)) => { + let path = &entry.path(); + + if entry_is_dir(&entry).await? { + continue; + } + + let mut file = open_async_file(&path).await?; + let file_url = hash_file(SelectedHash::Sha256, &mut file, &path).await?; + + if url == file_url { + tx.send(PrinterCmd::id(&path, &url, *format)).await?; + return Ok(()); + } + } + } + } + + Ok(()) +} diff --git a/omnibor/src/bin/omnibor/fs.rs b/omnibor/src/bin/omnibor/fs.rs new file mode 100644 index 0000000..3666a76 --- /dev/null +++ b/omnibor/src/bin/omnibor/fs.rs @@ -0,0 +1,98 @@ +//! File system helper operations. + +use crate::cli::Format; +use crate::cli::SelectedHash; +use crate::print::PrinterCmd; +use anyhow::Context as _; +use anyhow::Result; +use async_walkdir::DirEntry as AsyncDirEntry; +use async_walkdir::WalkDir; +use futures_lite::stream::StreamExt as _; +use omnibor::ArtifactId; +use omnibor::Sha256; +use std::path::Path; +use tokio::fs::File as AsyncFile; +use tokio::sync::mpsc::Sender; +use url::Url; + +// Identify, recursively, all the files under a directory. +pub async fn id_directory( + tx: &Sender, + path: &Path, + format: Format, + hash: SelectedHash, +) -> Result<()> { + let mut entries = WalkDir::new(path); + + loop { + match entries.next().await { + None => break, + Some(Err(e)) => tx.send(PrinterCmd::error(e, format)).await?, + Some(Ok(entry)) => { + let path = &entry.path(); + + if entry_is_dir(&entry).await? { + continue; + } + + let mut file = open_async_file(&path).await?; + id_file(tx, &mut file, &path, format, hash).await?; + } + } + } + + Ok(()) +} + +/// Identify a single file. +pub async fn id_file( + tx: &Sender, + file: &mut AsyncFile, + path: &Path, + format: Format, + hash: SelectedHash, +) -> Result<()> { + let url = hash_file(hash, file, &path).await?; + tx.send(PrinterCmd::id(path, &url, format)).await?; + Ok(()) +} + +/// Hash the file and produce a `gitoid`-scheme URL. +pub async fn hash_file(hash: SelectedHash, file: &mut AsyncFile, path: &Path) -> Result { + match hash { + SelectedHash::Sha256 => sha256_id_async_file(file, &path).await.map(|id| id.url()), + } +} + +/// Check if the file is for a directory. +pub async fn file_is_dir(file: &AsyncFile) -> Result { + Ok(file.metadata().await.map(|meta| meta.is_dir())?) +} + +/// Check if the entry is for a directory. +pub async fn entry_is_dir(entry: &AsyncDirEntry) -> Result { + entry + .file_type() + .await + .with_context(|| { + format!( + "unable to identify file type for '{}'", + entry.path().display() + ) + }) + .map(|file_type| file_type.is_dir()) +} + +/// Open an asynchronous file. +pub async fn open_async_file(path: &Path) -> Result { + AsyncFile::open(path) + .await + .with_context(|| format!("failed to open file '{}'", path.display())) +} + +/// Identify a file using a SHA-256 hash. +pub async fn sha256_id_async_file(file: &mut AsyncFile, path: &Path) -> Result> { + ArtifactId::id_async_reader(file) + .await + .with_context(|| format!("failed to produce Artifact ID for '{}'", path.display())) +} diff --git a/omnibor/src/bin/omnibor/id.rs b/omnibor/src/bin/omnibor/id.rs new file mode 100644 index 0000000..6d53866 --- /dev/null +++ b/omnibor/src/bin/omnibor/id.rs @@ -0,0 +1,18 @@ +//! The `id` command, which identifies files. + +use crate::cli::IdArgs; +use crate::fs::*; +use crate::print::PrinterCmd; +use anyhow::Result; +use tokio::sync::mpsc::Sender; + +/// Run the `id` subcommand. +pub async fn run(tx: &Sender, args: &IdArgs) -> Result<()> { + let mut file = open_async_file(&args.path).await?; + + if file_is_dir(&file).await? { + id_directory(tx, &args.path, args.format, args.hash).await + } else { + id_file(tx, &mut file, &args.path, args.format, args.hash).await + } +} diff --git a/omnibor/src/bin/omnibor/main.rs b/omnibor/src/bin/omnibor/main.rs new file mode 100644 index 0000000..9281f1d --- /dev/null +++ b/omnibor/src/bin/omnibor/main.rs @@ -0,0 +1,40 @@ +mod cli; +mod find; +mod fs; +mod id; +mod print; + +use crate::cli::Cli; +use crate::cli::Command; +use crate::print::Printer; +use crate::print::PrinterCmd; +use anyhow::Result; +use clap::Parser; +use std::process::ExitCode; +use tokio::sync::mpsc::Sender; + +#[tokio::main] +async fn main() -> ExitCode { + let args = Cli::parse(); + let printer = Printer::launch(args.buffer); + + let exit_code = match run(printer.tx(), &args.command).await { + Ok(_) => ExitCode::SUCCESS, + Err(e) => { + printer.send(PrinterCmd::error(e, args.format())).await; + ExitCode::FAILURE + } + }; + + printer.send(PrinterCmd::End).await; + printer.join().await; + exit_code +} + +/// Select and run the chosen chosen. +async fn run(tx: &Sender, cmd: &Command) -> Result<()> { + match cmd { + Command::Id(ref args) => id::run(tx, args).await, + Command::Find(ref args) => find::run(tx, args).await, + } +} diff --git a/omnibor/src/bin/omnibor/print.rs b/omnibor/src/bin/omnibor/print.rs new file mode 100644 index 0000000..9590346 --- /dev/null +++ b/omnibor/src/bin/omnibor/print.rs @@ -0,0 +1,199 @@ +//! Defines a simple print queue abstraction. + +use crate::cli::Format; +use anyhow::Error; +use anyhow::Result; +use serde_json::json; +use serde_json::Value as JsonValue; +use std::fmt::Display; +use std::fmt::Formatter; +use std::fmt::Result as FmtResult; +use std::future::Future; +use std::panic; +use std::path::Path; +use std::result::Result as StdResult; +use tokio::io::AsyncWrite; +use tokio::io::AsyncWriteExt as _; +use tokio::sync::mpsc; +use tokio::sync::mpsc::Sender; +use tokio::task::JoinError; +use url::Url; + +const DEFAULT_BUFFER_SIZE: usize = 100; + +/// A handle to assist in interacting with the printer. +pub struct Printer { + /// The transmitter to send message to the task. + tx: Sender, + + /// The actual future to be awaited. + task: Box> + Unpin>, +} + +impl Printer { + /// Launch the print queue task, give back sender and future for it. + pub fn launch(buffer_size: Option) -> Printer { + let (tx, mut rx) = mpsc::channel::(buffer_size.unwrap_or(DEFAULT_BUFFER_SIZE)); + + let printer = tokio::spawn(async move { + while let Some(msg) = rx.recv().await { + match msg { + PrinterCmd::End => break, + PrinterCmd::Message(msg) => { + // TODO(alilleybrinker): Handle this error. + let _ = msg.print().await; + } + } + } + rx.close(); + }); + + Printer { + tx, + task: Box::new(printer), + } + } + + /// Send a message to the print task. + pub async fn send(&self, cmd: PrinterCmd) { + self.tx + .send(cmd) + .await + .expect("print task is awaited and should still be receiving") + } + + /// Wait on the underlying task. + /// + /// This function waits, and then either returns normally or panics. + pub async fn join(self) { + if let Err(error) = self.task.await { + // If the print task panicked, the whole task should panic. + if error.is_panic() { + panic::resume_unwind(error.into_panic()) + } + + if error.is_cancelled() { + panic!("the printer task was cancelled unexpectedly"); + } + } + } + + /// Get a reference to the task transmitter. + pub fn tx(&self) -> &Sender { + &self.tx + } +} + +/// A print queue message, either an actual message or a signals to end printing. +#[derive(Debug, Clone)] +pub enum PrinterCmd { + End, + Message(Msg), +} + +impl PrinterCmd { + /// Construct a new ID printer command. + pub fn id(path: &Path, url: &Url, format: Format) -> Self { + PrinterCmd::Message(Msg::id(path, url, format)) + } + + /// Construct a new error printer command. + pub fn error>(error: E, format: Format) -> PrinterCmd { + PrinterCmd::Message(Msg::error(error, format)) + } +} + +/// An individual message to be printed. +#[derive(Debug, Clone)] +pub struct Msg { + /// The message content. + content: Content, + + /// The status associated with the message. + status: Status, +} + +impl Msg { + /// Construct a new ID message. + pub fn id(path: &Path, url: &Url, format: Format) -> Self { + let status = Status::Success; + let path = path.display().to_string(); + let url = url.to_string(); + + match format { + Format::Plain => Msg::plain(status, &format!("{} => {}", path, url)), + Format::Short => Msg::plain(status, &format!("{}", url)), + Format::Json => Msg::json(status, json!({ "path": path, "id": url })), + } + } + + /// Construct a new error message. + pub fn error>(error: E, format: Format) -> Msg { + fn _error(error: Error, format: Format) -> Msg { + let status = Status::Error; + + match format { + Format::Plain | Format::Short => { + Msg::plain(status, &format!("error: {}", error.to_string())) + } + Format::Json => Msg::json(status, json!({"error": error.to_string()})), + } + } + + _error(error.into(), format) + } + + /// Construct a new plain Msg. + fn plain(status: Status, s: &str) -> Self { + Msg { + content: Content::Plain(s.to_string()), + status, + } + } + + /// Construct a new JSON Msg. + fn json(status: Status, j: JsonValue) -> Self { + Msg { + content: Content::Json(j), + status, + } + } + + /// Print the Msg to the appropriate sink. + async fn print(self) -> Result<()> { + let to_output = self.content.to_string(); + self.resolve_sink().write_all(to_output.as_bytes()).await?; + Ok(()) + } + + /// Get the sink associated with the type of Msg. + fn resolve_sink(&self) -> Box { + match self.status { + Status::Success => Box::new(tokio::io::stdout()), + Status::Error => Box::new(tokio::io::stderr()), + } + } +} + +/// The actual content of a message. +#[derive(Debug, Clone)] +pub enum Content { + Json(JsonValue), + Plain(String), +} + +impl Display for Content { + fn fmt(&self, f: &mut Formatter<'_>) -> FmtResult { + match self { + Content::Plain(s) => writeln!(f, "{}", s), + Content::Json(j) => writeln!(f, "{}", j), + } + } +} + +/// Whether the message is a success or error. +#[derive(Debug, Clone, Copy)] +pub enum Status { + Success, + Error, +}