use super::{ReportableCrate, common};
use crate::Result;
use crate::expr::Risk;
use crate::metrics::{Metric, MetricCategory};
use core::fmt::Write;
use owo_colors::OwoColorize;
use std::borrow::Cow;
use crate::HashMap;
use strum::IntoEnumIterator;
use terminal_size::{Width, terminal_size};
#[derive(Debug, Clone)]
pub struct ConsoleOutputMode {
pub appraisal: bool,
pub reasons: bool,
pub metrics: bool,
}
impl ConsoleOutputMode {
#[must_use]
pub const fn full() -> Self {
Self { appraisal: true, reasons: true, metrics: true }
}
}
pub fn generate<W: Write>(crates: &[ReportableCrate], use_colors: bool, mode: &ConsoleOutputMode, writer: &mut W) -> Result<()> {
for (index, crate_info) in crates.iter().enumerate() {
if index > 0 && (mode.metrics || mode.reasons) {
writeln!(writer)?;
writeln!(writer, "═══════════════════════════════════════")?;
writeln!(writer)?;
}
if mode.appraisal {
if let Some(eval) = &crate_info.appraisal {
let status_str = common::format_appraisal_status(eval);
let colored_status: Cow<'_, str> = if use_colors {
match eval.risk {
Risk::Low => status_str.green().bold().to_string().into(),
Risk::Medium => status_str.yellow().bold().to_string().into(),
Risk::High => status_str.red().bold().to_string().into(),
}
} else {
Cow::Owned(status_str)
};
writeln!(writer, "{} v{} is appraised as {colored_status}", crate_info.name, crate_info.version)?;
if mode.reasons {
for outcome in &eval.expression_outcomes {
writeln!(writer, " {}", common::outcome_icon_name(outcome))?;
}
}
} else {
writeln!(writer, "{} v{} was not appraised", crate_info.name, crate_info.version)?;
}
}
if !mode.metrics {
continue;
}
let metric_map: HashMap<&str, &Metric> = crate_info.metrics.iter().map(|m| (m.name(), m)).collect();
let metrics_by_category = common::group_metrics_by_category(&crate_info.metrics);
for category in MetricCategory::iter() {
if let Some(metric_names) = metrics_by_category.get(&category) {
writeln!(writer)?;
if use_colors {
let category_str = category.to_string();
writeln!(writer, "{}", category_str.bold())?;
} else {
writeln!(writer, "{category}")?;
}
let max_name_len = metric_names.iter().map(|name| name.len()).max().unwrap_or(0);
let term_width = get_terminal_width();
let value_indent = 2 + max_name_len + 3;
for &metric_name in metric_names {
if let Some(&metric) = metric_map.get(metric_name) {
let formatted_value: Cow<'_, str> = metric.value.as_ref().map_or(Cow::Borrowed("n/a"), |v| Cow::Owned(common::format_metric_value(v)));
let wrapped_lines = wrap_text(&formatted_value, term_width, value_indent);
if let Some(first_line) = wrapped_lines.first() {
writeln!(writer, " {:<width$} : {}", metric.name(), first_line, width = max_name_len)?;
for line in wrapped_lines.iter().skip(1) {
writeln!(writer, "{line}")?;
}
}
}
}
}
}
}
Ok(())
}
fn get_terminal_width() -> usize {
terminal_size().map_or(80, |(Width(w), _)| w as usize)
}
fn wrap_text(text: &str, width: usize, indent: usize) -> Vec<String> {
if width <= indent {
return vec![text.to_string()];
}
let mut lines = Vec::new();
let mut current_line = String::new();
let mut is_first_line = true;
for word in text.split_whitespace() {
let word_len = word.len();
let separator_len = usize::from(!current_line.is_empty()); let line_width = if is_first_line {
current_line.len()
} else {
indent + current_line.len()
};
if !current_line.is_empty() && line_width + separator_len + word_len > width {
if is_first_line {
lines.push(current_line);
is_first_line = false;
} else {
lines.push(format!("{:indent$}{}", "", current_line, indent = indent));
}
current_line = word.to_string();
} else {
if !current_line.is_empty() {
current_line.push(' ');
}
current_line.push_str(word);
}
}
if !current_line.is_empty() {
if is_first_line {
lines.push(current_line);
} else {
lines.push(format!("{:indent$}{}", "", current_line, indent = indent));
}
}
if lines.is_empty() {
lines.push(String::new());
}
lines
}
#[cfg(test)]
mod tests {
use super::*;
use crate::expr::{Appraisal, ExpressionDisposition, ExpressionOutcome, Risk};
use crate::metrics::{MetricDef, MetricValue};
use std::sync::Arc;
static NAME_DEF: MetricDef = MetricDef {
name: "name",
description: "Crate name",
category: MetricCategory::Metadata,
extractor: |_| None,
default_value: || None,
};
static VERSION_DEF: MetricDef = MetricDef {
name: "version",
description: "Crate version",
category: MetricCategory::Metadata,
extractor: |_| None,
default_value: || None,
};
fn create_test_crate(name: &str, version: &str, evaluation: Option<Appraisal>) -> ReportableCrate {
let metrics = vec![
Metric::with_value(&NAME_DEF, MetricValue::String(name.into())),
Metric::with_value(&VERSION_DEF, MetricValue::String(version.into())),
];
ReportableCrate::new(name.into(), Arc::new(version.parse().unwrap()), metrics, evaluation)
}
#[test]
fn test_generate_empty_crates() {
let crates: Vec<ReportableCrate> = vec![];
let mut output = String::new();
let result = generate(&crates, false, &ConsoleOutputMode::full(), &mut output);
result.unwrap();
assert_eq!(output, "");
}
#[test]
fn test_generate_single_crate_no_evaluation() {
let crates = vec![create_test_crate("test_crate", "1.0.0", None)];
let mut output = String::new();
let result = generate(&crates, false, &ConsoleOutputMode::full(), &mut output);
result.unwrap();
assert!(!output.contains("Evaluation Result"));
assert!(!output.contains("RISK"));
}
#[test]
fn test_generate_single_crate_with_evaluation_accepted() {
let eval = Appraisal {
risk: Risk::Low,
expression_outcomes: vec![ExpressionOutcome::new("quality".into(), "Good quality".into(), ExpressionDisposition::True)],
available_points: 1,
awarded_points: 1,
score: 100.0,
};
let crates = vec![create_test_crate("test_crate", "1.0.0", Some(eval))];
let mut output = String::new();
let result = generate(&crates, false, &ConsoleOutputMode::full(), &mut output);
result.unwrap();
assert!(output.contains("appraised as"));
assert!(output.contains("LOW RISK"));
}
#[test]
fn test_generate_single_crate_with_evaluation_denied() {
let eval = Appraisal {
risk: Risk::High,
expression_outcomes: vec![ExpressionOutcome::new("security".into(), "Security issues".into(), ExpressionDisposition::False)],
available_points: 1,
awarded_points: 0,
score: 0.0,
};
let crates = vec![create_test_crate("test_crate", "1.0.0", Some(eval))];
let mut output = String::new();
let result = generate(&crates, false, &ConsoleOutputMode::full(), &mut output);
result.unwrap();
assert!(output.contains("appraised as"));
assert!(output.contains("HIGH RISK"));
}
#[test]
fn test_generate_multiple_crates() {
let crates = vec![create_test_crate("zebra", "1.0.0", None), create_test_crate("alpha", "2.0.0", None)];
let mut output = String::new();
let result = generate(&crates, false, &ConsoleOutputMode::full(), &mut output);
result.unwrap();
assert!(output.contains("═══════════════════════════════════════"));
}
#[test]
fn test_generate_color_mode_never() {
let eval = Appraisal {
risk: Risk::Low,
expression_outcomes: vec![],
available_points: 0,
awarded_points: 0,
score: 100.0,
};
let crates = vec![create_test_crate("test", "1.0.0", Some(eval))];
let mut output = String::new();
let result = generate(&crates, false, &ConsoleOutputMode::full(), &mut output);
result.unwrap();
assert!(!output.contains("\x1b["));
}
#[test]
fn test_wrap_text_short() {
let text = "short text";
let lines = wrap_text(text, 80, 10);
assert_eq!(lines.len(), 1);
assert_eq!(lines[0], "short text");
}
#[test]
fn test_wrap_text_long() {
let text = "This is a very long text that should be wrapped at word boundaries when it exceeds the specified width";
let lines = wrap_text(text, 40, 10);
assert!(lines.len() > 1);
assert!(!lines[0].starts_with(' '));
if lines.len() > 1 {
assert!(lines[1].starts_with(" ")); }
}
#[test]
fn test_wrap_text_exact_fit() {
let text = "word1 word2 word3";
let lines = wrap_text(text, 17, 5);
assert_eq!(lines.len(), 1);
}
#[test]
fn test_wrap_text_empty() {
let text = "";
let lines = wrap_text(text, 80, 10);
assert_eq!(lines.len(), 1);
assert_eq!(lines[0], "");
}
}