quantus-cli 2.1.1

Command line interface and library for interacting with the Quantus Network
//! Result collection and rendering.

use colored::Colorize;
use serde::Serialize;

#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
#[serde(rename_all = "lowercase")]
pub enum StepStatus {
	Passed,
	Failed,
	Skipped,
}

#[derive(Debug, Clone, Serialize)]
pub struct StepResult {
	pub phase: String,
	pub name: String,
	pub status: StepStatus,
	pub detail: String,
	pub duration_ms: u128,
}

#[derive(Debug, Serialize)]
pub struct Report {
	pub node_url: String,
	pub seed: u64,
	pub spec_version: u32,
	pub steps: Vec<StepResult>,
	#[serde(skip)]
	pub fail_fast: bool,
}

impl Report {
	pub fn new(node_url: &str, seed: u64, spec_version: u32, fail_fast: bool) -> Self {
		Self { node_url: node_url.to_string(), seed, spec_version, steps: Vec::new(), fail_fast }
	}

	pub fn record(
		&mut self,
		phase: &str,
		name: &str,
		elapsed: std::time::Duration,
		result: crate::error::Result<String>,
	) {
		let (status, detail) = match result {
			Ok(detail) => (StepStatus::Passed, detail),
			Err(e) => (StepStatus::Failed, e.to_string()),
		};
		self.push(phase, name, status, detail, elapsed.as_millis());
	}

	pub fn record_skip(&mut self, phase: &str, name: &str, reason: &str) {
		self.push(phase, name, StepStatus::Skipped, reason.to_string(), 0);
	}

	fn push(&mut self, phase: &str, name: &str, status: StepStatus, detail: String, ms: u128) {
		let icon = match status {
			StepStatus::Passed => "",
			StepStatus::Failed => "",
			StepStatus::Skipped => "⏭️ ",
		};
		crate::log_status!(
			"{} [{}] {} — {} ({}ms)",
			icon,
			phase.bright_blue(),
			name.bold(),
			truncate(&detail, 160),
			ms
		);
		self.steps.push(StepResult {
			phase: phase.to_string(),
			name: name.to_string(),
			status,
			detail,
			duration_ms: ms,
		});
	}

	pub fn count(&self, status: StepStatus) -> usize {
		self.steps.iter().filter(|s| s.status == status).count()
	}

	pub fn has_failures(&self) -> bool {
		self.count(StepStatus::Failed) > 0
	}

	pub fn should_abort(&self) -> bool {
		self.fail_fast && self.has_failures()
	}

	pub fn render_summary(&self) {
		let passed = self.count(StepStatus::Passed);
		let failed = self.count(StepStatus::Failed);
		let skipped = self.count(StepStatus::Skipped);

		crate::log_status!("");
		crate::log_status!("{}", "================ Exercise Summary ================".bold());
		crate::log_status!("   Node: {}   Seed: {}", self.node_url.bright_cyan(), self.seed);
		crate::log_status!(
			"   {} passed, {} failed, {} skipped ({} total)",
			passed.to_string().bright_green(),
			failed.to_string().bright_red(),
			skipped.to_string().bright_yellow(),
			self.steps.len()
		);
		if failed > 0 {
			crate::log_status!("");
			crate::log_status!("{}", "Failed steps:".bright_red().bold());
			for step in self.steps.iter().filter(|s| s.status == StepStatus::Failed) {
				crate::log_status!(
					"   ❌ [{}] {} — {}",
					step.phase.bright_blue(),
					step.name.bold(),
					truncate(&step.detail, 300)
				);
			}
		}
		crate::log_status!("{}", "==================================================".bold());
	}

	pub fn render_json(&self) -> crate::error::Result<String> {
		serde_json::to_string_pretty(self).map_err(Into::into)
	}
}

fn truncate(s: &str, max: usize) -> String {
	let single_line = s.replace('\n', " ");
	if single_line.chars().count() <= max {
		single_line
	} else {
		let cut: String = single_line.chars().take(max).collect();
		format!("{cut}")
	}
}