diff --git a/omnibor-cli/Cargo.toml b/omnibor-cli/Cargo.toml index 881d62e..a4a0f72 100644 --- a/omnibor-cli/Cargo.toml +++ b/omnibor-cli/Cargo.toml @@ -28,14 +28,16 @@ path = "src/main.rs" [dependencies] -anyhow = "1.0.80" async-walkdir = "1.0.0" clap = { version = "4.5.1", features = ["derive", "env"] } +clap-verbosity-flag = "2.2.2" dirs = "5.0.1" +dyn-clone = "1.0.17" futures-lite = "2.2.0" omnibor = { version = "0.6.0", path = "../omnibor" } pathbuf = "1.0.0" serde_json = "1.0.114" +thiserror = "2.0.3" tokio = { version = "1.36.0", features = [ "fs", "io-std", diff --git a/omnibor-cli/src/cli.rs b/omnibor-cli/src/cli.rs index 5df5583..f15d4ea 100644 --- a/omnibor-cli/src/cli.rs +++ b/omnibor-cli/src/cli.rs @@ -1,19 +1,16 @@ //! Defines the Command Line Interface. -#![allow(unused)] - -use anyhow::anyhow; -use omnibor::hashes::Sha256; -use omnibor::ArtifactId; -use omnibor::IntoArtifactId; +use crate::error::Error; +use clap_verbosity_flag::{InfoLevel, Verbosity}; +use omnibor::{hashes::Sha256, ArtifactId, IntoArtifactId}; use pathbuf::pathbuf; -use std::default::Default; -use std::fmt::Display; -use std::fmt::Formatter; -use std::path::Path; -use std::path::PathBuf; -use std::str::FromStr; -use std::sync::OnceLock; +use std::{ + default::Default, + fmt::{Display, Formatter}, + path::{Path, PathBuf}, + str::FromStr, + sync::OnceLock, +}; // The default root directory for OmniBOR. // We use a `static` here to make sure we can safely give out @@ -71,6 +68,9 @@ pub struct Config { )] dir: Option, + #[command(flatten)] + pub verbose: Verbosity, + #[command(subcommand)] command: Option, } @@ -234,13 +234,13 @@ pub enum IdentifiableArg { } impl FromStr for IdentifiableArg { - type Err = anyhow::Error; + type Err = Error; fn from_str(s: &str) -> Result { match (ArtifactId::from_str(s), PathBuf::from_str(s)) { (Ok(aid), _) => Ok(IdentifiableArg::ArtifactId(aid)), (_, Ok(path)) => Ok(IdentifiableArg::Path(path)), - (Err(_), Err(_)) => Err(anyhow!("input not recognized as Artifact ID or file path")), + (Err(_), Err(_)) => Err(Error::NotIdentifiable(s.to_string())), } } } diff --git a/omnibor-cli/src/artifact_find.rs b/omnibor-cli/src/cmd/artifact/find.rs similarity index 50% rename from omnibor-cli/src/artifact_find.rs rename to omnibor-cli/src/cmd/artifact/find.rs index 42557e5..b1b29db 100644 --- a/omnibor-cli/src/artifact_find.rs +++ b/omnibor-cli/src/cmd/artifact/find.rs @@ -1,11 +1,11 @@ //! The `artifact find` command, which finds files by ID. -use crate::cli::Config; -use crate::cli::FindArgs; -use crate::cli::SelectedHash; -use crate::fs::*; -use crate::print::PrinterCmd; -use anyhow::Result; +use crate::{ + cli::{Config, FindArgs, SelectedHash}, + error::{Error, Result}, + fs::*, + print::{error::ErrorMsg, find_file::FindFileMsg, PrinterCmd}, +}; use async_walkdir::WalkDir; use futures_lite::stream::StreamExt as _; use tokio::sync::mpsc::Sender; @@ -21,7 +21,16 @@ pub async fn run(tx: &Sender, config: &Config, args: &FindArgs) -> R loop { match entries.next().await { None => break, - Some(Err(e)) => tx.send(PrinterCmd::error(e, config.format())).await?, + Some(Err(source)) => tx + .send(PrinterCmd::msg( + ErrorMsg::new(Error::WalkDirFailed { + path: path.to_path_buf(), + source, + }), + config.format(), + )) + .await + .map_err(|_| Error::PrintChannelClose)?, Some(Ok(entry)) => { let path = &entry.path(); @@ -33,8 +42,15 @@ pub async fn run(tx: &Sender, config: &Config, args: &FindArgs) -> R let file_url = hash_file(SelectedHash::Sha256, &mut file, path).await?; if url == file_url { - tx.send(PrinterCmd::find(path, &url, config.format())) - .await?; + tx.send(PrinterCmd::msg( + FindFileMsg { + path: path.to_path_buf(), + id: url.clone(), + }, + config.format(), + )) + .await + .map_err(|_| Error::PrintChannelClose)?; } } } diff --git a/omnibor-cli/src/artifact_id.rs b/omnibor-cli/src/cmd/artifact/id.rs similarity index 75% rename from omnibor-cli/src/artifact_id.rs rename to omnibor-cli/src/cmd/artifact/id.rs index a3d8296..7a7b7fe 100644 --- a/omnibor-cli/src/artifact_id.rs +++ b/omnibor-cli/src/cmd/artifact/id.rs @@ -1,17 +1,18 @@ //! The `artifact id` command, which identifies files. -use crate::cli::Config; -use crate::cli::IdArgs; -use crate::fs::*; -use crate::print::PrinterCmd; -use anyhow::Result; +use crate::{ + cli::{Config, IdArgs}, + error::Result, + fs::*, + print::PrinterCmd, +}; use tokio::sync::mpsc::Sender; /// Run the `artifact id` subcommand. pub async fn run(tx: &Sender, config: &Config, args: &IdArgs) -> Result<()> { let mut file = open_async_file(&args.path).await?; - if file_is_dir(&file).await? { + if file_is_dir(&file, &args.path).await? { id_directory(tx, &args.path, config.format(), config.hash()).await?; } else { id_file(tx, &mut file, &args.path, config.format(), config.hash()).await?; diff --git a/omnibor-cli/src/cmd/artifact/mod.rs b/omnibor-cli/src/cmd/artifact/mod.rs new file mode 100644 index 0000000..dd5848d --- /dev/null +++ b/omnibor-cli/src/cmd/artifact/mod.rs @@ -0,0 +1,2 @@ +pub mod find; +pub mod id; diff --git a/omnibor-cli/src/cmd/debug/config.rs b/omnibor-cli/src/cmd/debug/config.rs new file mode 100644 index 0000000..cd42c49 --- /dev/null +++ b/omnibor-cli/src/cmd/debug/config.rs @@ -0,0 +1,19 @@ +//! The `debug config` command, which helps debug the CLI configuration. + +use crate::{ + cli::Config, + error::{Error, Result}, + print::{root_dir::RootDirMsg, PrinterCmd}, +}; +use tokio::sync::mpsc::Sender; + +/// Run the `debug config` subcommand. +pub async fn run(tx: &Sender, config: &Config) -> Result<()> { + let root = config.dir().ok_or(Error::NoRoot)?.to_path_buf(); + + tx.send(PrinterCmd::msg(RootDirMsg { path: root }, config.format())) + .await + .map_err(|_| Error::PrintChannelClose)?; + + Ok(()) +} diff --git a/omnibor-cli/src/cmd/debug/mod.rs b/omnibor-cli/src/cmd/debug/mod.rs new file mode 100644 index 0000000..ef68c36 --- /dev/null +++ b/omnibor-cli/src/cmd/debug/mod.rs @@ -0,0 +1 @@ +pub mod config; diff --git a/omnibor-cli/src/manifest_add.rs b/omnibor-cli/src/cmd/manifest/add.rs similarity index 70% rename from omnibor-cli/src/manifest_add.rs rename to omnibor-cli/src/cmd/manifest/add.rs index 835da43..c006c39 100644 --- a/omnibor-cli/src/manifest_add.rs +++ b/omnibor-cli/src/cmd/manifest/add.rs @@ -1,9 +1,10 @@ //! The `manifest add` command, which adds manifests. -use crate::cli::Config; -use crate::cli::ManifestAddArgs; -use crate::print::PrinterCmd; -use anyhow::Result; +use crate::{ + cli::{Config, ManifestAddArgs}, + error::Result, + print::PrinterCmd, +}; use tokio::sync::mpsc::Sender; /// Run the `manifest add` subcommand. diff --git a/omnibor-cli/src/cmd/manifest/create.rs b/omnibor-cli/src/cmd/manifest/create.rs new file mode 100644 index 0000000..47b7297 --- /dev/null +++ b/omnibor-cli/src/cmd/manifest/create.rs @@ -0,0 +1,51 @@ +//! The `manifest create` command, which creates manifests. + +use crate::{ + cli::{Config, ManifestCreateArgs}, + error::{Error, Result}, + print::PrinterCmd, +}; +use omnibor::{ + embedding::NoEmbed, hashes::Sha256, storage::FileSystemStorage, InputManifestBuilder, + IntoArtifactId, RelationKind, +}; +use tokio::sync::mpsc::Sender; +use tracing::info; + +/// Run the `manifest create` subcommand. +pub async fn run( + _tx: &Sender, + config: &Config, + args: &ManifestCreateArgs, +) -> Result<()> { + let root = config.dir().ok_or_else(|| Error::NoRoot)?; + + info!(root = %root.display()); + + let storage = FileSystemStorage::new(root).map_err(Error::StorageInitFailed)?; + + let mut builder = InputManifestBuilder::::with_storage(storage); + + for input in &args.inputs { + let aid = input.clone().into_artifact_id().map_err(Error::IdFailed)?; + builder + .add_relation(RelationKind::Input, aid) + .map_err(Error::AddRelationFailed)?; + } + + if let Some(built_by) = &args.built_by { + let aid = built_by + .clone() + .into_artifact_id() + .map_err(Error::IdFailed)?; + builder + .add_relation(RelationKind::BuiltBy, aid) + .map_err(Error::AddRelationFailed)?; + } + + builder + .finish(&args.target) + .map_err(Error::ManifestBuildFailed)?; + + Ok(()) +} diff --git a/omnibor-cli/src/cmd/manifest/mod.rs b/omnibor-cli/src/cmd/manifest/mod.rs new file mode 100644 index 0000000..595cac0 --- /dev/null +++ b/omnibor-cli/src/cmd/manifest/mod.rs @@ -0,0 +1,3 @@ +pub mod add; +pub mod create; +pub mod remove; diff --git a/omnibor-cli/src/manifest_remove.rs b/omnibor-cli/src/cmd/manifest/remove.rs similarity index 70% rename from omnibor-cli/src/manifest_remove.rs rename to omnibor-cli/src/cmd/manifest/remove.rs index b60ec50..2b47d32 100644 --- a/omnibor-cli/src/manifest_remove.rs +++ b/omnibor-cli/src/cmd/manifest/remove.rs @@ -1,9 +1,10 @@ //! The `manifest remove` command, which removes manifests. -use crate::cli::Config; -use crate::cli::ManifestRemoveArgs; -use crate::print::PrinterCmd; -use anyhow::Result; +use crate::{ + cli::{Config, ManifestRemoveArgs}, + error::Result, + print::PrinterCmd, +}; use tokio::sync::mpsc::Sender; /// Run the `manifest remove` subcommand. diff --git a/omnibor-cli/src/cmd/mod.rs b/omnibor-cli/src/cmd/mod.rs new file mode 100644 index 0000000..7d796b2 --- /dev/null +++ b/omnibor-cli/src/cmd/mod.rs @@ -0,0 +1,5 @@ +//! Defines individual subcommands. + +pub mod artifact; +pub mod debug; +pub mod manifest; diff --git a/omnibor-cli/src/debug_config.rs b/omnibor-cli/src/debug_config.rs deleted file mode 100644 index 144a04b..0000000 --- a/omnibor-cli/src/debug_config.rs +++ /dev/null @@ -1,13 +0,0 @@ -//! The `debug config` command, which helps debug the CLI configuration. - -use crate::cli::Config; -use crate::print::PrinterCmd; -use anyhow::Result; -use tokio::sync::mpsc::Sender; - -/// Run the `debug config` subcommand. -pub async fn run(tx: &Sender, config: &Config) -> Result<()> { - tx.send(PrinterCmd::root_dir(config.dir(), config.format())) - .await?; - Ok(()) -} diff --git a/omnibor-cli/src/error.rs b/omnibor-cli/src/error.rs new file mode 100644 index 0000000..9821f76 --- /dev/null +++ b/omnibor-cli/src/error.rs @@ -0,0 +1,67 @@ +//! Error types. + +use omnibor::Error as OmniborError; +use std::{io::Error as IoError, path::PathBuf, result::Result as StdResult}; + +#[derive(Debug, thiserror::Error)] +pub enum Error { + #[error("could not identify '{0}'")] + NotIdentifiable(String), + + #[error("could not find root directory")] + NoRoot, + + #[error("failed to initialize file system storage")] + StorageInitFailed(#[source] OmniborError), + + #[error("failed to generate Artifact ID")] + IdFailed(#[source] OmniborError), + + #[error("failed to add relation to Input Manifest")] + AddRelationFailed(#[source] OmniborError), + + #[error("failed to build Input Manifest")] + ManifestBuildFailed(#[source] OmniborError), + + #[error("failed to write to stdout")] + StdoutWriteFailed(#[source] IoError), + + #[error("failed to write to stderr")] + StderrWriteFailed(#[source] IoError), + + #[error("failed walking under directory '{}'", path.display())] + WalkDirFailed { path: PathBuf, source: IoError }, + + #[error("unable to identify file type for '{}'", path.display())] + UnknownFileType { + path: PathBuf, + #[source] + source: IoError, + }, + + #[error("failed to open file '{}'", path.display())] + FileFailedToOpen { + path: PathBuf, + #[source] + source: IoError, + }, + + #[error("failed to get file metadata '{}'", path.display())] + FileFailedMetadata { + path: PathBuf, + #[source] + source: IoError, + }, + + #[error("failed to make Artifact ID for '{}'", path.display())] + FileFailedToId { + path: PathBuf, + #[source] + source: OmniborError, + }, + + #[error("print channel closed")] + PrintChannelClose, +} + +pub type Result = StdResult; diff --git a/omnibor-cli/src/fs.rs b/omnibor-cli/src/fs.rs index 8960833..ef7fb15 100644 --- a/omnibor-cli/src/fs.rs +++ b/omnibor-cli/src/fs.rs @@ -1,18 +1,15 @@ //! 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 crate::{ + cli::{Format, SelectedHash}, + error::{Error, Result}, + print::{error::ErrorMsg, id_file::IdFileMsg, PrinterCmd}, +}; +use async_walkdir::{DirEntry as AsyncDirEntry, WalkDir}; use futures_lite::stream::StreamExt as _; -use omnibor::hashes::Sha256; -use omnibor::ArtifactId; +use omnibor::{hashes::Sha256, ArtifactId}; use std::path::Path; -use tokio::fs::File as AsyncFile; -use tokio::sync::mpsc::Sender; +use tokio::{fs::File as AsyncFile, sync::mpsc::Sender}; use url::Url; // Identify, recursively, all the files under a directory. @@ -27,7 +24,16 @@ pub async fn id_directory( loop { match entries.next().await { None => break, - Some(Err(e)) => tx.send(PrinterCmd::error(e, format)).await?, + Some(Err(source)) => tx + .send(PrinterCmd::msg( + ErrorMsg::new(Error::WalkDirFailed { + path: path.to_path_buf(), + source, + }), + format, + )) + .await + .map_err(|_| Error::PrintChannelClose)?, Some(Ok(entry)) => { let path = &entry.path(); @@ -53,7 +59,17 @@ pub async fn id_file( hash: SelectedHash, ) -> Result<()> { let url = hash_file(hash, file, path).await?; - tx.send(PrinterCmd::id(path, &url, format)).await?; + + tx.send(PrinterCmd::msg( + IdFileMsg { + path: path.to_path_buf(), + id: url.clone(), + }, + format, + )) + .await + .map_err(|_| Error::PrintChannelClose)?; + Ok(()) } @@ -65,8 +81,14 @@ pub async fn hash_file(hash: SelectedHash, file: &mut AsyncFile, path: &Path) -> } /// 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())?) +pub async fn file_is_dir(file: &AsyncFile, path: &Path) -> Result { + file.metadata() + .await + .map(|meta| meta.is_dir()) + .map_err(|source| Error::FileFailedMetadata { + path: path.to_path_buf(), + source, + }) } /// Check if the entry is for a directory. @@ -74,25 +96,29 @@ 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()) + .map_err(|source| Error::UnknownFileType { + path: entry.path(), + source, + }) } /// 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())) + .map_err(|source| Error::FileFailedToOpen { + path: path.to_path_buf(), + source, + }) } /// 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())) + .map_err(|source| Error::FileFailedToId { + path: path.to_path_buf(), + source, + }) } diff --git a/omnibor-cli/src/main.rs b/omnibor-cli/src/main.rs index c5373bc..f8567b4 100644 --- a/omnibor-cli/src/main.rs +++ b/omnibor-cli/src/main.rs @@ -1,23 +1,20 @@ -mod artifact_find; -mod artifact_id; mod cli; -mod debug_config; +mod cmd; +mod error; mod fs; -mod manifest_add; -mod manifest_create; -mod manifest_remove; mod print; -use crate::cli::Command; -use crate::cli::Config; -use crate::cli::ManifestCommand; -use crate::print::Printer; -use crate::print::PrinterCmd; -use anyhow::Result; +use crate::{ + cli::{ArtifactCommand, Command, Config, DebugCommand, ManifestCommand}, + cmd::{artifact, debug, manifest}, + error::{Error, Result}, + print::{error::ErrorMsg, Printer, PrinterCmd}, +}; use clap::Parser as _; -use cli::ArtifactCommand; +use clap_verbosity_flag::{InfoLevel, Verbosity}; use std::process::ExitCode; use tokio::sync::mpsc::Sender; +use tracing::trace; use tracing_subscriber::filter::EnvFilter; // The environment variable to use when configuring the log. @@ -25,10 +22,10 @@ const LOG_VAR: &str = "OMNIBOR_LOG"; #[tokio::main] async fn main() -> ExitCode { - init_log(); let config = Config::parse(); + init_log(&config.verbose); let printer = Printer::launch(config.buffer()); - tracing::trace!("config: {:#?}", config); + trace!(config = ?config); match run(printer.tx(), &config).await { Ok(_) => { @@ -36,39 +33,69 @@ async fn main() -> ExitCode { ExitCode::SUCCESS } Err(e) => { - printer.send(PrinterCmd::error(e, config.format())).await; + printer + .send(PrinterCmd::msg(ErrorMsg::new(e), config.format())) + .await; printer.join().await; ExitCode::FAILURE } } } -// Initialize the logging / tracing. -fn init_log() { +/// Initialize the logging / tracing. +fn init_log(verbosity: &Verbosity) { + let level_filter = adapt_level_filter(verbosity.log_level_filter()); + let filter = EnvFilter::from_env(LOG_VAR).add_directive(level_filter.into()); + + let format = tracing_subscriber::fmt::format() + .with_level(true) + .without_time() + .with_target(false) + .with_thread_ids(false) + .with_thread_names(false) + .compact(); + tracing_subscriber::fmt() - .with_env_filter(EnvFilter::from_env(LOG_VAR)) + .event_format(format) + .with_env_filter(filter) .init(); } +/// Convert the clap LevelFilter to the tracing LevelFilter. +fn adapt_level_filter( + clap_filter: clap_verbosity_flag::LevelFilter, +) -> tracing_subscriber::filter::LevelFilter { + match clap_filter { + clap_verbosity_flag::LevelFilter::Off => tracing_subscriber::filter::LevelFilter::OFF, + clap_verbosity_flag::LevelFilter::Error => tracing_subscriber::filter::LevelFilter::ERROR, + clap_verbosity_flag::LevelFilter::Warn => tracing_subscriber::filter::LevelFilter::WARN, + clap_verbosity_flag::LevelFilter::Info => tracing_subscriber::filter::LevelFilter::INFO, + clap_verbosity_flag::LevelFilter::Debug => tracing_subscriber::filter::LevelFilter::DEBUG, + clap_verbosity_flag::LevelFilter::Trace => tracing_subscriber::filter::LevelFilter::TRACE, + } +} + /// Select and run the chosen command. async fn run(tx: &Sender, config: &Config) -> Result<()> { match config.command() { Command::Artifact(ref args) => match args.command() { - ArtifactCommand::Id(ref args) => artifact_id::run(tx, config, args).await?, - ArtifactCommand::Find(ref args) => artifact_find::run(tx, config, args).await?, + ArtifactCommand::Id(ref args) => artifact::id::run(tx, config, args).await?, + ArtifactCommand::Find(ref args) => artifact::find::run(tx, config, args).await?, }, Command::Manifest(ref args) => match args.command() { - ManifestCommand::Add(ref args) => manifest_add::run(tx, config, args).await?, - ManifestCommand::Remove(ref args) => manifest_remove::run(tx, config, args).await?, - ManifestCommand::Create(ref args) => manifest_create::run(tx, config, args).await?, + ManifestCommand::Add(ref args) => manifest::add::run(tx, config, args).await?, + ManifestCommand::Remove(ref args) => manifest::remove::run(tx, config, args).await?, + ManifestCommand::Create(ref args) => manifest::create::run(tx, config, args).await?, }, Command::Debug(ref args) => match args.command() { - cli::DebugCommand::Config => debug_config::run(tx, config).await?, + DebugCommand::Config => debug::config::run(tx, config).await?, }, } // Ensure we always send the "End" printer command. - tx.send(PrinterCmd::End).await?; + tx.send(PrinterCmd::End) + .await + .map_err(|_| Error::PrintChannelClose)?; Ok(()) } diff --git a/omnibor-cli/src/manifest_create.rs b/omnibor-cli/src/manifest_create.rs deleted file mode 100644 index bab43e8..0000000 --- a/omnibor-cli/src/manifest_create.rs +++ /dev/null @@ -1,45 +0,0 @@ -//! The `manifest create` command, which creates manifests. - -use crate::cli::Config; -use crate::cli::ManifestCreateArgs; -use crate::print::PrinterCmd; -use anyhow::anyhow; -use anyhow::Result; -use omnibor::embedding::NoEmbed; -use omnibor::hashes::Sha256; -use omnibor::storage::FileSystemStorage; -use omnibor::InputManifestBuilder; -use omnibor::IntoArtifactId; -use omnibor::RelationKind; -use tokio::sync::mpsc::Sender; - -/// Run the `manifest create` subcommand. -pub async fn run( - _tx: &Sender, - config: &Config, - args: &ManifestCreateArgs, -) -> Result<()> { - let root = config - .dir() - .ok_or_else(|| anyhow!("no root directory found"))?; - - tracing::info!("selected OmniBOR root dir '{}'", root.display()); - - let storage = FileSystemStorage::new(root)?; - - let mut builder = InputManifestBuilder::::with_storage(storage); - - for input in &args.inputs { - let aid = input.clone().into_artifact_id()?; - builder.add_relation(RelationKind::Input, aid)?; - } - - if let Some(built_by) = &args.built_by { - let aid = built_by.clone().into_artifact_id()?; - builder.add_relation(RelationKind::BuiltBy, aid)?; - } - - builder.finish(&args.target)?; - - Ok(()) -} diff --git a/omnibor-cli/src/print.rs b/omnibor-cli/src/print.rs deleted file mode 100644 index c9024f5..0000000 --- a/omnibor-cli/src/print.rs +++ /dev/null @@ -1,274 +0,0 @@ -//! 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::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 std::{fmt::Display, io::Write}; -use tokio::io::stderr; -use tokio::io::stdout; -use tokio::io::AsyncWriteExt as _; -use tokio::sync::mpsc; -use tokio::sync::mpsc::Sender; -use tokio::task::JoinError; -use tracing::debug; -use url::Url; - -/// 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: usize) -> Printer { - let (tx, mut rx) = mpsc::channel::(buffer_size); - - let printer = tokio::spawn(async move { - while let Some(msg) = rx.recv().await { - debug!(?msg); - - match msg { - // Closing the stream ensures it still drains if there are messages in flight. - PrinterCmd::End => rx.close(), - PrinterCmd::Message(msg) => { - let format = msg.format(); - let msg_clone = msg.clone(); - - if let Err(err) = msg.print().await { - // Fallback to only sync printing if the async printing failed. - let err_msg = Msg::error(err, format); - - if let Err(err) = err_msg.sync_print() { - panic!("failed to print sync error message: '{}'", err); - } - - if let Err(err) = msg_clone.sync_print() { - panic!("failed to print sync message: '{}'", err); - } - } - } - } - } - }); - - 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 { - /// Shut down the printer task. - End, - - /// Print the following message. - 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 find printer command. - pub fn find(path: &Path, url: &Url, format: Format) -> Self { - PrinterCmd::Message(Msg::find(path, url, format)) - } - - /// Construct a new error printer command. - pub fn error>(error: E, format: Format) -> PrinterCmd { - PrinterCmd::Message(Msg::error(error, format)) - } - - pub fn root_dir(dir: Option<&Path>, format: Format) -> PrinterCmd { - PrinterCmd::Message(Msg::root_dir(dir, 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, &url.to_string()), - Format::Json => Msg::json(status, json!({ "path": path, "id": url })), - } - } - - /// Construct a new find message. - pub fn find(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!("{} => {}", url, path)), - Format::Short => Msg::plain(status, &path.to_string()), - Format::Json => Msg::json(status, json!({ "path": path, "id": url })), - } - } - - pub fn root_dir(dir: Option<&Path>, format: Format) -> Self { - let status = Status::Success; - let dir = match dir { - Some(path) => path.display().to_string(), - None => String::from("no OmniBOR root directory provided"), - }; - - match format { - Format::Plain => Msg::plain(status, &format!("root_dir: {}", dir)), - Format::Short => Msg::plain(status, &dir.to_string()), - Format::Json => Msg::json(status, json!({ "root_dir": dir })), - } - } - - /// 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)), - 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, - } - } - - /// Get the format of the message. - fn format(&self) -> Format { - match self.content { - Content::Json(_) => Format::Json, - Content::Plain(_) => Format::Plain, - } - } - - /// Print the Msg to the appropriate sink. - async fn print(self) -> Result<()> { - let to_output = self.content.to_string(); - self.write(to_output.as_bytes()).await?; - Ok(()) - } - - /// Print the contents of the message synchronously. - fn sync_print(self) -> Result<()> { - let to_output = self.content.to_string(); - let bytes = to_output.as_bytes(); - - match self.status { - Status::Success => std::io::stdout().write_all(bytes)?, - Status::Error => std::io::stderr().write_all(bytes)?, - } - - Ok(()) - } - - /// Write bytes to the correct sink. - async fn write(&self, bytes: &[u8]) -> Result<()> { - match self.status { - Status::Success => stdout().write_all(bytes).await?, - Status::Error => stderr().write_all(bytes).await?, - } - - Ok(()) - } -} - -/// 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, -} diff --git a/omnibor-cli/src/print/error.rs b/omnibor-cli/src/print/error.rs new file mode 100644 index 0000000..14987db --- /dev/null +++ b/omnibor-cli/src/print/error.rs @@ -0,0 +1,42 @@ +use crate::{ + error::Error, + print::{CommandOutput, Status}, +}; +use serde_json::json; +use std::sync::{Arc, Mutex}; + +#[derive(Debug, Clone)] +pub struct ErrorMsg { + pub error: Arc>, +} + +impl ErrorMsg { + pub fn new(error: Error) -> Self { + ErrorMsg { + error: Arc::new(Mutex::new(error)), + } + } + + fn error_string(&self) -> String { + // SAFETY: This error type should only have a singular owner anyway. + self.error.lock().unwrap().to_string() + } +} + +impl CommandOutput for ErrorMsg { + fn plain_output(&self) -> String { + format!("error: {}", self.error_string()) + } + + fn short_output(&self) -> String { + self.error_string() + } + + fn json_output(&self) -> serde_json::Value { + json!({"error": self.error_string()}) + } + + fn status(&self) -> Status { + Status::Error + } +} diff --git a/omnibor-cli/src/print/find_file.rs b/omnibor-cli/src/print/find_file.rs new file mode 100644 index 0000000..20162ff --- /dev/null +++ b/omnibor-cli/src/print/find_file.rs @@ -0,0 +1,38 @@ +use crate::print::{CommandOutput, Status}; +use serde_json::json; +use std::path::PathBuf; +use url::Url; + +#[derive(Debug, Clone)] +pub struct FindFileMsg { + pub path: PathBuf, + pub id: Url, +} + +impl FindFileMsg { + fn path_string(&self) -> String { + self.path.display().to_string() + } + + fn id_string(&self) -> String { + self.id.to_string() + } +} + +impl CommandOutput for FindFileMsg { + fn plain_output(&self) -> String { + format!("{} => {}", self.id_string(), self.path_string()) + } + + fn short_output(&self) -> String { + self.path_string() + } + + fn json_output(&self) -> serde_json::Value { + json!({"path": self.path_string(), "id": self.id_string()}) + } + + fn status(&self) -> Status { + Status::Success + } +} diff --git a/omnibor-cli/src/print/id_file.rs b/omnibor-cli/src/print/id_file.rs new file mode 100644 index 0000000..c3c3ee9 --- /dev/null +++ b/omnibor-cli/src/print/id_file.rs @@ -0,0 +1,38 @@ +use crate::print::{CommandOutput, Status}; +use serde_json::json; +use std::path::PathBuf; +use url::Url; + +#[derive(Debug, Clone)] +pub struct IdFileMsg { + pub path: PathBuf, + pub id: Url, +} + +impl IdFileMsg { + fn path_string(&self) -> String { + self.path.display().to_string() + } + + fn id_string(&self) -> String { + self.id.to_string() + } +} + +impl CommandOutput for IdFileMsg { + fn plain_output(&self) -> String { + format!("{} => {}", self.path_string(), self.id_string()) + } + + fn short_output(&self) -> String { + self.id_string() + } + + fn json_output(&self) -> serde_json::Value { + json!({"path": self.path_string(), "id": self.id_string()}) + } + + fn status(&self) -> Status { + Status::Success + } +} diff --git a/omnibor-cli/src/print/mod.rs b/omnibor-cli/src/print/mod.rs new file mode 100644 index 0000000..3d9f5bf --- /dev/null +++ b/omnibor-cli/src/print/mod.rs @@ -0,0 +1,211 @@ +//! Defines a simple print queue abstraction. + +pub mod error; +pub mod find_file; +pub mod id_file; +pub mod root_dir; + +use crate::{ + cli::Format, + error::{Error, Result}, +}; +use dyn_clone::{clone_box, DynClone}; +use error::ErrorMsg; +use serde_json::Value as JsonValue; +use std::{ + fmt::Debug, + future::Future, + io::Write, + ops::{Deref, Not}, + panic, + result::Result as StdResult, +}; +use tokio::{ + io::{stderr, stdout, AsyncWriteExt as _}, + sync::mpsc::{self, Sender}, + task::JoinError, +}; +use tracing::{debug, error}; + +/// 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: usize) -> Printer { + let (tx, mut rx) = mpsc::channel::(buffer_size); + + let printer = tokio::spawn(async move { + while let Some(msg) = rx.recv().await { + debug!(msg = ?msg); + + match msg { + // Closing the stream ensures it still drains if there are messages in flight. + PrinterCmd::End => rx.close(), + PrinterCmd::Message { output, format } => { + let status = output.status(); + let output = output.format(format); + + if let Err(error) = print(status, output.clone()).await { + let err_output = ErrorMsg::new(error).format(format); + + if let Err(err) = sync_print(Status::Error, err_output) { + error!(msg = "failed to print sync error message", error = %err); + } + + if let Err(err) = sync_print(status, output) { + error!(msg = "failed to print sync message", error = %err); + } + } + } + } + } + }); + + Printer { + tx, + task: Box::new(printer), + } + } + + /// Send a message to the print task. + pub async fn send(&self, cmd: PrinterCmd) { + if let Err(e) = self.tx.send(cmd.clone()).await { + error!(msg = "failed to send printer cmd", cmd = ?cmd, error = %e); + } + } + + /// 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() { + error!(msg = "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 { + /// Shut down the printer task. + End, + + /// Print the following message. + Message { output: Msg, format: Format }, +} + +impl PrinterCmd { + /// Construct a new ID printer command. + pub fn msg(output: C, format: Format) -> Self { + PrinterCmd::Message { + output: Msg::new(output), + format, + } + } +} + +#[derive(Debug)] +pub struct Msg(Box); + +impl Clone for Msg { + fn clone(&self) -> Self { + Msg(clone_box(self.0.deref())) + } +} + +impl Deref for Msg { + type Target = Box; + + fn deref(&self) -> &Self::Target { + &self.0 + } +} + +impl Msg { + fn new(output: C) -> Self { + Msg(Box::new(output)) + } +} + +/// Trait representing the different possible outputs that can arise. +pub trait CommandOutput: Debug + DynClone + Send + 'static { + fn plain_output(&self) -> String; + fn short_output(&self) -> String; + fn json_output(&self) -> JsonValue; + fn status(&self) -> Status; + + fn format(&self, format: Format) -> String { + let mut output = match format { + Format::Plain => self.plain_output(), + // SAFETY: serde_json::Value can always be converted to a string. + Format::Json => serde_json::to_string(&self.json_output()).unwrap(), + Format::Short => self.short_output(), + }; + + if output.ends_with('\n').not() { + output.push('\n'); + } + + output + } +} + +/// Print to the appropriate sink. +async fn print(status: Status, output: String) -> Result<()> { + let bytes = output.as_bytes(); + + match status { + Status::Success => stdout() + .write_all(bytes) + .await + .map_err(Error::StdoutWriteFailed)?, + Status::Error => stderr() + .write_all(bytes) + .await + .map_err(Error::StderrWriteFailed)?, + } + + Ok(()) +} + +/// Print the contents of the message synchronously. +fn sync_print(status: Status, output: String) -> Result<()> { + let bytes = output.as_bytes(); + + match status { + Status::Success => std::io::stdout() + .write_all(bytes) + .map_err(Error::StdoutWriteFailed)?, + Status::Error => std::io::stderr() + .write_all(bytes) + .map_err(Error::StderrWriteFailed)?, + } + + Ok(()) +} + +/// Whether the message is a success or error. +#[derive(Debug, Clone, Copy)] +pub enum Status { + Success, + Error, +} diff --git a/omnibor-cli/src/print/root_dir.rs b/omnibor-cli/src/print/root_dir.rs new file mode 100644 index 0000000..9f87642 --- /dev/null +++ b/omnibor-cli/src/print/root_dir.rs @@ -0,0 +1,32 @@ +use crate::print::{CommandOutput, Status}; +use serde_json::json; +use std::path::PathBuf; + +#[derive(Debug, Clone)] +pub struct RootDirMsg { + pub path: PathBuf, +} + +impl RootDirMsg { + fn path_string(&self) -> String { + self.path.display().to_string() + } +} + +impl CommandOutput for RootDirMsg { + fn plain_output(&self) -> String { + format!("root_dir: {}", self.path_string()) + } + + fn short_output(&self) -> String { + self.path_string() + } + + fn json_output(&self) -> serde_json::Value { + json!({"path": self.path_string()}) + } + + fn status(&self) -> Status { + Status::Success + } +} diff --git a/omnibor-cli/tests/snapshots/test__artifact_no_args.snap b/omnibor-cli/tests/snapshots/test__artifact_no_args.snap index 2888f65..a178624 100644 --- a/omnibor-cli/tests/snapshots/test__artifact_no_args.snap +++ b/omnibor-cli/tests/snapshots/test__artifact_no_args.snap @@ -20,8 +20,10 @@ Commands: help Print this message or the help of the given subcommand(s) Options: - -h, --help Print help (see more with '--help') - -V, --version Print version + -v, --verbose... Increase logging verbosity + -q, --quiet... Decrease logging verbosity + -h, --help Print help (see more with '--help') + -V, --version Print version General Flags: -b, --buffer How many print messages to buffer at once [env: OMNIBOR_BUFFER=] [default: 100] diff --git a/omnibor-cli/tests/snapshots/test__debug_no_args.snap b/omnibor-cli/tests/snapshots/test__debug_no_args.snap index aa3fb13..4cc5b32 100644 --- a/omnibor-cli/tests/snapshots/test__debug_no_args.snap +++ b/omnibor-cli/tests/snapshots/test__debug_no_args.snap @@ -19,8 +19,10 @@ Commands: help Print this message or the help of the given subcommand(s) Options: - -h, --help Print help (see more with '--help') - -V, --version Print version + -v, --verbose... Increase logging verbosity + -q, --quiet... Decrease logging verbosity + -h, --help Print help (see more with '--help') + -V, --version Print version General Flags: -b, --buffer How many print messages to buffer at once [env: OMNIBOR_BUFFER=] [default: 100] diff --git a/omnibor-cli/tests/snapshots/test__manifest_no_args.snap b/omnibor-cli/tests/snapshots/test__manifest_no_args.snap index 1a98dff..d4db5b3 100644 --- a/omnibor-cli/tests/snapshots/test__manifest_no_args.snap +++ b/omnibor-cli/tests/snapshots/test__manifest_no_args.snap @@ -21,8 +21,10 @@ Commands: help Print this message or the help of the given subcommand(s) Options: - -h, --help Print help (see more with '--help') - -V, --version Print version + -v, --verbose... Increase logging verbosity + -q, --quiet... Decrease logging verbosity + -h, --help Print help (see more with '--help') + -V, --version Print version General Flags: -b, --buffer How many print messages to buffer at once [env: OMNIBOR_BUFFER=] [default: 100] diff --git a/omnibor-cli/tests/snapshots/test__no_args.snap b/omnibor-cli/tests/snapshots/test__no_args.snap index 451d3b2..9795973 100644 --- a/omnibor-cli/tests/snapshots/test__no_args.snap +++ b/omnibor-cli/tests/snapshots/test__no_args.snap @@ -20,8 +20,10 @@ Commands: help Print this message or the help of the given subcommand(s) Options: - -h, --help Print help (see more with '--help') - -V, --version Print version + -v, --verbose... Increase logging verbosity + -q, --quiet... Decrease logging verbosity + -h, --help Print help (see more with '--help') + -V, --version Print version General Flags: -b, --buffer How many print messages to buffer at once [env: OMNIBOR_BUFFER=] [default: 100]