diff --git a/CHANGELOG.md b/CHANGELOG.md index b10915b9..0acfd23a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Added config validations. - sfsu will now crash with an error message if `no_junction` is enabled. - Added `app download --outdated` flag to download new versions of all outdated apps +- Warnings in search command for deprecated usage +- Support `json` flag in `search` command ### Changed @@ -19,6 +21,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Download progress bars now show app name instead of url leaf - Download hash checks now report to a progress bar rather than a print message for each - Logs will now go into `/logs` if running with debug assertions +- `search` command no longer hides `[installed]` label if only searching for installed apps ### Removed diff --git a/Cargo.lock b/Cargo.lock index 5074bff0..f416ebe0 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3759,7 +3759,7 @@ dependencies = [ [[package]] name = "sfsu" -version = "1.15.0" +version = "1.16.0" dependencies = [ "anyhow", "bat", diff --git a/Cargo.toml b/Cargo.toml index fc608785..db6e93e6 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -6,7 +6,7 @@ license = "MIT OR Apache-2.0" name = "sfsu" publish = true repository = "https://github.com/winpax/sfsu" -version = "1.15.0" +version = "1.16.0" [[bench]] harness = false diff --git a/src/commands/app/download.rs b/src/commands/app/download.rs index 7ee74dbc..89e5cca4 100644 --- a/src/commands/app/download.rs +++ b/src/commands/app/download.rs @@ -30,9 +30,6 @@ use crate::{ #[derive(Debug, Clone, Parser)] /// Download the specified app. pub struct Args { - #[clap(short, long, help = "Use the specified architecture, if the app supports it", default_value_t = Architecture::ARCH)] - arch: Architecture, - #[clap(short = 'H', long, help = "Disable hash validation")] no_hash_check: bool, @@ -41,6 +38,9 @@ pub struct Args { #[clap(long, help = "Download new versions of all outdated apps")] outdated: bool, + + #[clap(from_global)] + arch: Architecture, } impl super::Command for Args { diff --git a/src/commands/search.rs b/src/commands/search.rs index f1882eef..42d37616 100644 --- a/src/commands/search.rs +++ b/src/commands/search.rs @@ -1,3 +1,5 @@ +use std::collections::HashMap; + use itertools::Itertools; use rayon::prelude::*; @@ -8,12 +10,16 @@ use sprinkles::{ buckets::Bucket, contexts::ScoopContext, packages::{Manifest, MergeDefaults, SearchMode}, + version::Version, Architecture, }; use crate::{ calm_panic::CalmUnwrap, - output::sectioned::{Children, Section, Sections, Text}, + output::{ + sectioned::{Children, Section, Sections, Text}, + warning, + }, }; #[derive(Debug, Clone)] @@ -36,42 +42,45 @@ impl MatchCriteria { /// Check if the name matches pub fn matches( file_name: &str, - manifest: Option<&Manifest>, - mode: SearchMode, pattern: &Regex, - arch: Architecture, + list_binaries: impl FnOnce() -> Vec, + mode: SearchMode, ) -> Self { - let file_name = file_name.to_string(); - let mut output = MatchCriteria::new(); - if mode.match_names() && pattern.is_match(&file_name) { - output.name = true; + if mode.match_names() { + output.match_names(pattern, file_name); } - if let Some(manifest) = manifest { - let binaries = manifest - .architecture - .merge_default(manifest.install_config.clone(), arch) - .bin - .map(|b| b.to_vec()) - .unwrap_or_default(); + if mode.match_binaries() { + output.match_binaries(pattern, list_binaries()); + } - let binary_matches = binaries - .into_iter() - .filter(|binary| pattern.is_match(binary)) - .filter_map(|b| { - if pattern.is_match(&b) { - Some(b.clone()) - } else { - None - } - }); + output + } - output.bins.extend(binary_matches); + fn match_names(&mut self, pattern: &Regex, file_name: &str) -> &mut Self { + if pattern.is_match(file_name) { + self.name = true; } + self + } - output + fn match_binaries(&mut self, pattern: &Regex, binaries: Vec) -> &mut Self { + let binary_matches = binaries + .into_iter() + .filter(|binary| pattern.is_match(binary)) + .filter_map(|b| { + if pattern.is_match(&b) { + Some(b.clone()) + } else { + None + } + }); + + self.bins.extend(binary_matches); + + self } } @@ -81,79 +90,124 @@ impl Default for MatchCriteria { } } -pub fn parse_output( - manifest: &Manifest, - ctx: &impl ScoopContext, - bucket: impl AsRef, - installed_only: bool, - pattern: &Regex, - mode: SearchMode, - arch: Architecture, -) -> Option>> { - // TODO: Better display of output +struct MatchedManifest { + manifest: Manifest, + installed: bool, + name_matched: bool, + bins: Vec, + exact_match: bool, +} - // This may be a bit of a hack, but it works +impl MatchedManifest { + pub fn new( + ctx: &impl ScoopContext, + manifest: Manifest, + pattern: &Regex, + mode: SearchMode, + arch: Architecture, + ) -> MatchedManifest { + // TODO: Better display of output + let bucket = unsafe { manifest.bucket() }; + + let match_output = MatchCriteria::matches( + unsafe { manifest.name() }, + pattern, + // Function to list binaries from a manifest + // Passed as a closure to avoid this parsing if bin matching isn't required + || { + manifest + .architecture + .merge_default(manifest.install_config.clone(), arch) + .bin + .map(|b| b.to_vec()) + .unwrap_or_default() + }, + mode, + ); + + let installed = manifest.is_installed(ctx, Some(bucket)); + let exact_match = unsafe { manifest.name() } == pattern.to_string(); + + MatchedManifest { + manifest, + installed, + name_matched: match_output.name, + bins: match_output.bins, + exact_match, + } + } - let match_output = MatchCriteria::matches( - unsafe { manifest.name() }, - if mode.match_binaries() { - Some(manifest) + pub fn should_match(&self, installed_only: bool) -> bool { + if !self.installed && installed_only { + return false; + } + if !self.name_matched && self.bins.is_empty() { + return false; + } + + true + } + + pub fn to_section(&self) -> Section> { + let styled_package_name = if self.exact_match { + console::style(unsafe { self.manifest.name() }) + .bold() + .to_string() + } else { + unsafe { self.manifest.name() }.to_string() + }; + + let installed_text = if self.installed { "[installed] " } else { "" }; + + let title = format!( + "{styled_package_name} ({}) {installed_text}", + self.manifest.version + ); + + if self.bins.is_empty() { + Section::new(Children::None) } else { - None - }, - mode, - pattern, - arch, - ); - - if !match_output.name && match_output.bins.is_empty() { - return None; + let bins = self + .bins + .iter() + .map(|output| { + Text::new(format!( + "{}{}", + crate::output::WHITESPACE, + console::style(output).bold() + )) + }) + .collect_vec(); + + Section::new(Children::from(bins)) + } + .with_title(title) } - let is_installed = manifest.is_installed(ctx, Some(bucket.as_ref())); - if installed_only && !is_installed { - return None; + pub fn into_output(self) -> MatchedOutput { + MatchedOutput { + name: unsafe { self.manifest.name() }.to_string(), + bucket: unsafe { self.manifest.bucket() }.to_string(), + version: self.manifest.version.clone(), + installed: self.installed, + bins: self.bins, + } } +} - let styled_package_name = if unsafe { manifest.name() } == pattern.to_string() { - console::style(unsafe { manifest.name() }) - .bold() - .to_string() - } else { - unsafe { manifest.name() }.to_string() - }; - - let installed_text = if is_installed && !installed_only { - "[installed] " - } else { - "" - }; - - let title = format!( - "{styled_package_name} ({}) {installed_text}", - manifest.version - ); - - let package = if mode.match_binaries() { - let bins = match_output - .bins - .iter() - .map(|output| { - Text::new(format!( - "{}{}", - crate::output::WHITESPACE, - console::style(output).bold() - )) - }) - .collect_vec(); - - Section::new(Children::from(bins)) - } else { - Section::new(Children::None) +impl std::fmt::Display for MatchedManifest { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + std::fmt::Display::fmt(&self.to_section(), f) } - .with_title(title); +} - Some(package) +#[derive(Debug, serde::Serialize)] +struct MatchedOutput { + name: String, + bucket: String, + version: Version, + installed: bool, + bins: Vec, } #[derive(Debug, Clone, Parser)] @@ -177,23 +231,35 @@ pub struct Args { #[clap(short, long, help = "Search mode to use", default_value_t)] mode: SearchMode, - // TODO: Add json option - // #[clap(from_global)] - // json: bool, + + #[clap(from_global)] + arch: Architecture, + + #[clap(from_global)] + json: bool, } impl super::Command for Args { async fn runner(self, ctx: &impl ScoopContext) -> Result<(), anyhow::Error> { - let (bucket, raw_pattern) = - if let Some((bucket, raw_pattern)) = self.pattern.split_once('/') { - // Bucket flag overrides bucket/package syntax - ( - Some(self.bucket.unwrap_or(bucket.to_string())), - raw_pattern.to_string(), - ) - } else { - (self.bucket, self.pattern) - }; + let (bucket, raw_pattern) = if let Some((bucket, raw_pattern)) = + self.pattern.split_once('/') + { + warning!("bucket/package syntax is deprecated. Please use the --bucket flag instead"); + ( + Some({ + // Bucket flag overrides bucket/package syntax + if let Some(bucket) = self.bucket { + warning!("Using bucket flag instead of bucket/package syntax"); + bucket + } else { + bucket.to_string() + } + }), + raw_pattern.to_string(), + ) + } else { + (self.bucket, self.pattern) + }; let pattern = { Regex::new(&format!( @@ -212,33 +278,25 @@ impl super::Command for Args { Bucket::list_all(ctx)? }; - let mut matches: Sections<_> = matching_buckets + let buckets: HashMap> = matching_buckets .par_iter() .filter_map( |bucket| match bucket.matches(ctx, self.installed, &pattern, self.mode) { - Ok(manifest) => { - let sections = manifest + Ok(manifests) => { + let matches = manifests .into_par_iter() - .filter_map(|manifest| { - parse_output( - &manifest, - ctx, - unsafe { manifest.bucket() }, - self.installed, - &pattern, - self.mode, - Architecture::ARCH, - ) + .map(|manifest| { + MatchedManifest::new(ctx, manifest, &pattern, self.mode, self.arch) + }) + .filter(|matched_manifest| { + matched_manifest.should_match(self.installed) }) .collect::>(); - if sections.is_empty() { + if matches.is_empty() { None } else { - let section = Section::new(Children::from(sections)) - .with_title(format!("'{}' bucket:", bucket.name())); - - Some(section) + Some((bucket.name().to_string(), matches)) } } _ => None, @@ -246,9 +304,39 @@ impl super::Command for Args { ) .collect(); - matches.par_sort(); + if self.json { + let json_matches: HashMap> = buckets + .into_iter() + .map(|(bucket, matches)| { + let bucket_matches: Vec = matches + .into_iter() + .map(MatchedManifest::into_output) + .collect(); + + (bucket, bucket_matches) + }) + .collect(); - print!("{matches}"); + serde_json::to_writer_pretty(std::io::stdout(), &json_matches)?; + } else { + let mut matches: Sections<_> = buckets + .into_iter() + .map(|(bucket, matches)| { + let mut sections = vec![]; + + matches + .par_iter() + .map(MatchedManifest::to_section) + .collect_into_vec(&mut sections); + + Section::new(Children::from(sections)).with_title(format!("'{bucket}' bucket:")) + }) + .collect(); + + matches.par_sort(); + + print!("{matches}"); + } Ok(()) } diff --git a/src/commands/virustotal.rs b/src/commands/virustotal.rs index bb0db0f1..64c6e8fb 100644 --- a/src/commands/virustotal.rs +++ b/src/commands/virustotal.rs @@ -112,12 +112,7 @@ pub struct Args { )] filter: Option, - #[clap( - short, - long, - help = "Use the specified architecture, if the app supports it", - default_value_t = Architecture::ARCH - )] + #[clap(from_global)] arch: Architecture, #[clap(short = 'A', long, help = "Scan all installed apps")] diff --git a/src/main.rs b/src/main.rs index 86476a97..1a4ad06c 100644 --- a/src/main.rs +++ b/src/main.rs @@ -27,7 +27,10 @@ use clap::Parser; use commands::{Commands, Runnable}; use logging::Logger; -use sprinkles::contexts::{AnyContext, ScoopContext, User}; +use sprinkles::{ + contexts::{AnyContext, ScoopContext, User}, + Architecture, +}; #[cfg(feature = "contexts")] use sprinkles::contexts::Global; @@ -118,6 +121,15 @@ struct Args { #[clap(short, long, global = true, help = "Use the global Scoop context")] global: bool, + #[clap( + short, + long, + global = true, + help = "Use the specified architecture, if the app and command support it", + default_value_t = Architecture::ARCH + )] + arch: Architecture, + #[clap( global = true, short = 'y', diff --git a/src/output.rs b/src/output.rs index 9e0016f5..2f33ee2e 100644 --- a/src/output.rs +++ b/src/output.rs @@ -12,3 +12,13 @@ pub mod truncate; /// Opinionated whitespace for formatting pub const WHITESPACE: &str = " "; + +#[macro_export] +#[doc = concat!("Print a colored string with the `yellow` color and a `WARN: ` prefix.")] +macro_rules! warning { + ($($arg:tt)*) => {{ + $crate::output::colours::eprintln_yellow!("WARN: {}", $crate::output::colours::black!($($arg)*)) + }}; +} + +pub use warning;