use std::time::Duration;
use indicatif::{ProgressBar, ProgressStyle};
use owo_colors::{OwoColorize, Stream};
use sysknife_brain::planner::{AuthorizedPlan, PlanRiskLevel};
use sysknife_types::{JobState, PreviewEnvelope, ResultEnvelope};
use crate::runner::Logger;
pub fn make_spinner(msg: impl Into<String>) -> ProgressBar {
let pb = ProgressBar::new_spinner();
pb.set_style(
ProgressStyle::default_spinner()
.tick_strings(&["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"])
.template("{spinner} {msg}")
.unwrap(),
);
pb.set_message(msg.into());
pb.enable_steady_tick(Duration::from_millis(80));
pb
}
pub fn risk_colored(risk: &PlanRiskLevel) -> String {
match risk {
PlanRiskLevel::Low => format!(
"● {}",
"low".if_supports_color(Stream::Stdout, |t| t.green())
),
PlanRiskLevel::Medium => format!(
"● {}",
"medium".if_supports_color(Stream::Stdout, |t| t.yellow())
),
PlanRiskLevel::High => format!(
"● {}",
"HIGH".if_supports_color(Stream::Stdout, |t| t.red().bold().to_string())
),
}
}
pub fn print_plan(plan: &AuthorizedPlan, log: &Logger) {
log.println("");
log.println(&format!(
" {}",
plan.summary()
.if_supports_color(Stream::Stdout, |t| t.bold())
));
log.println(&format!(
" {}",
"─"
.repeat(50)
.if_supports_color(Stream::Stdout, |t| t.dimmed())
));
for (i, step) in plan.steps().enumerate() {
let risk_badge = risk_colored(step.risk_level());
let approval_label = if step.approval_required() {
"approval required"
.if_supports_color(Stream::Stdout, |t| t.yellow())
.to_string()
} else {
"auto"
.if_supports_color(Stream::Stdout, |t| t.dimmed())
.to_string()
};
log.println(&format!(
" {} {:<32} {} {}",
format!("{}", i + 1).if_supports_color(Stream::Stdout, |t| t.dimmed()),
step.action_name()
.if_supports_color(Stream::Stdout, |t| t.bold()),
risk_badge,
approval_label,
));
log.println(&format!(
" {}",
step.summary()
.if_supports_color(Stream::Stdout, |t| t.dimmed())
));
}
log.println("");
}
pub fn print_step_header(action: &str, preview: &PreviewEnvelope) {
eprintln!(
"\n {} {} {}",
"▶".if_supports_color(Stream::Stderr, |t| t.cyan()),
action.if_supports_color(Stream::Stderr, |t| t.bold()),
preview
.summary
.if_supports_color(Stream::Stderr, |t| t.dimmed()),
);
if preview.reboot_required {
eprintln!(
" {} reboot required after this step",
"⚠".if_supports_color(Stream::Stderr, |t| t.yellow())
);
}
for w in &preview.warnings {
eprintln!(
" {} {w}",
"!".if_supports_color(Stream::Stderr, |t| t.yellow())
);
}
}
pub fn print_output_line(line: &str, log: &Logger) {
log.println(&format!(" › {line}"));
}
pub fn print_step_done(result: &ResultEnvelope, log: &Logger) {
let (icon, label) = match result.status {
JobState::Succeeded => (
"✓"
.if_supports_color(Stream::Stdout, |t| t.green())
.to_string(),
"succeeded",
),
JobState::Failed => (
"✗"
.if_supports_color(Stream::Stdout, |t| t.red())
.to_string(),
"failed",
),
JobState::NeedsReboot => (
"↺"
.if_supports_color(Stream::Stdout, |t| t.yellow())
.to_string(),
"needs reboot",
),
_ => (
"⚠"
.if_supports_color(Stream::Stdout, |t| t.yellow())
.to_string(),
"unknown",
),
};
log.println(&format!(" {icon} {} — {label}", result.summary));
if result.needs_reboot {
log.println(&format!(
" {} reboot required",
"⚠".if_supports_color(Stream::Stdout, |t| t.yellow())
));
}
for w in &result.warnings {
log.println(&format!(
" {} {w}",
"!".if_supports_color(Stream::Stdout, |t| t.yellow())
));
}
if let Some(ref id) = result.job_id {
log.println(&format!(" job {id}"));
}
}
pub fn print_success(elapsed_secs: f32, log: &Logger) {
log.println(&format!(
"\n{} succeeded {:.1}s\n",
"✓".if_supports_color(Stream::Stdout, |t| t.green()),
elapsed_secs,
));
}
pub fn print_doctor_ok(
socket: &str,
host: &str,
provider: &str,
model: &str,
distro: &str,
log: &Logger,
) {
log.println(&format!(
"{} daemon ok",
"✓".if_supports_color(Stream::Stdout, |t| t.green())
));
log.println(&format!(" socket {socket}"));
log.println(&format!(" host {host}"));
log.println(&format!(" provider {provider}"));
log.println(&format!(" model {model}"));
log.println(&format!(" distro {distro}"));
}
pub fn print_doctor_fail(error: &str) {
eprintln!(
"{} daemon unreachable: {error}",
"✗".if_supports_color(Stream::Stderr, |t| t.red()),
);
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn risk_colored_low_renders_low_with_a_dot_marker() {
let s = risk_colored(&PlanRiskLevel::Low);
assert!(
s.starts_with('●'),
"every risk badge must lead with the bullet glyph; got {s:?}"
);
assert!(
s.contains("low"),
"low-risk badge must mention 'low'; got {s:?}"
);
}
#[test]
fn risk_colored_medium_renders_medium_with_a_dot_marker() {
let s = risk_colored(&PlanRiskLevel::Medium);
assert!(s.starts_with('●'));
assert!(s.contains("medium"));
}
#[test]
fn risk_colored_high_renders_high_uppercase() {
let s = risk_colored(&PlanRiskLevel::High);
assert!(s.starts_with('●'));
assert!(
s.contains("HIGH"),
"high-risk badge must use uppercase HIGH; got {s:?}"
);
assert!(
!s.contains("high "),
"high-risk badge must NOT use lowercase 'high'; got {s:?}"
);
}
#[test]
fn risk_badges_are_distinct_so_a_skim_can_tell_them_apart() {
use std::collections::HashSet;
let set: HashSet<String> = [
risk_colored(&PlanRiskLevel::Low),
risk_colored(&PlanRiskLevel::Medium),
risk_colored(&PlanRiskLevel::High),
]
.into_iter()
.collect();
assert_eq!(
set.len(),
3,
"the three risk badges must render to distinct strings"
);
}
}