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::operator_text::operator_safe;
use crate::runner::Logger;
const SPINNER_TICK: Duration = Duration::from_millis(80);
const RULE_WIDTH: usize = 50;
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(SPINNER_TICK);
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!(
" {}",
operator_safe(plan.summary()).if_supports_color(Stream::Stdout, |t| t.bold())
));
log.println(&format!(
" {}",
"─"
.repeat(RULE_WIDTH)
.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!(
" {}",
operator_safe(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()),
operator_safe(&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!(
" {} {}",
"!".if_supports_color(Stream::Stderr, |t| t.yellow()),
operator_safe(w),
);
}
}
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}",
operator_safe(&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!(
" {} {}",
"!".if_supports_color(Stream::Stdout, |t| t.yellow()),
operator_safe(w),
));
}
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_planning_notice(provider: &str, model: &str, intent: &str) {
eprintln!("→ planning \"{intent}\" with {provider}/{model}, this can take a minute…");
}
fn doctor_fail_lines(socket: &str, error: &str) -> Vec<String> {
let headline = if error.contains(socket) {
format!("daemon unreachable: {error}")
} else {
format!("daemon unreachable at {socket}: {error}")
};
let mut lines = vec![headline];
if socket.starts_with("vsock://") {
lines.push("check the daemon on the target VM, and that SYSKNIFE_TOKEN matches".into());
return lines;
}
let user_mode = socket.contains("/run/user/");
if !user_mode && error.to_lowercase().contains("permission denied") {
lines.push("the daemon is listening, but this account may not open its socket".into());
lines.push(
"join the socket group and one role group: \
sudo usermod -aG sysknife,sysknife-admin \"$USER\""
.into(),
);
lines.push(
"role groups: sysknife-observer (read-only), sysknife-dev (medium risk), \
sysknife-admin (high risk)"
.into(),
);
lines.push("then log out and back in, or run: newgrp sysknife".into());
}
let system_hint = "system service: sudo systemctl status sysknife-daemon";
let user_hint = "user service: systemctl --user status sysknife-daemon";
if user_mode {
lines.push(user_hint.into());
lines.push(system_hint.into());
} else {
lines.push(system_hint.into());
lines.push(user_hint.into());
}
lines
}
pub fn print_doctor_fail(socket: &str, error: &str) {
let mut lines = doctor_fail_lines(socket, error).into_iter();
let headline = lines.next().expect("always at least one line");
eprintln!(
"{} {headline}",
"✗".if_supports_color(Stream::Stderr, |t| t.red()),
);
for hint in lines {
eprintln!(
" {} {hint}",
"→".if_supports_color(Stream::Stderr, |t| t.dimmed())
);
}
}
#[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 doctor_failure_names_the_socket_and_a_next_step() {
let out = doctor_fail_lines("unix:///run/sysknife/daemon.sock", "connect: No such file");
let joined = out.join("\n");
assert!(joined.contains("unix:///run/sysknife/daemon.sock"));
assert!(joined.contains("connect: No such file"), "keeps the cause");
assert!(
joined.contains("systemctl"),
"must offer a command to run, got: {joined}"
);
}
#[test]
fn a_system_socket_suggests_the_system_unit_first() {
let out = doctor_fail_lines("unix:///run/sysknife/daemon.sock", "boom").join("\n");
let system_at = out.find("sudo systemctl").expect("system hint present");
let user_at = out.find("systemctl --user").expect("user hint present");
assert!(
system_at < user_at,
"system hint first for /run, got: {out}"
);
}
#[test]
fn a_per_user_runtime_socket_suggests_the_user_unit_first() {
let out =
doctor_fail_lines("unix:///run/user/1000/sysknife/daemon.sock", "boom").join("\n");
let user_at = out.find("systemctl --user").expect("user hint present");
let system_at = out.find("sudo systemctl").expect("system hint present");
assert!(
user_at < system_at,
"user hint first for /run/user, got: {out}"
);
}
#[test]
fn permission_denied_names_the_group_fix_not_just_the_unit() {
let out = doctor_fail_lines(
"unix:///run/sysknife/daemon.sock",
"cannot reach the SysKnife daemon at unix:///run/sysknife/daemon.sock: \
Permission denied (os error 13)",
)
.join("\n");
assert!(
out.contains("usermod -aG"),
"must give the membership command, got: {out}"
);
assert!(
out.contains("sysknife-admin"),
"must name a role group, not only the socket group, got: {out}"
);
assert!(
out.to_lowercase().contains("log"),
"group changes need a new login to take effect; say so, got: {out}"
);
}
#[test]
fn permission_denied_hint_leads_the_report() {
let out = doctor_fail_lines(
"unix:///run/sysknife/daemon.sock",
"Permission denied (os error 13)",
)
.join("\n");
let group_at = out.find("usermod -aG").expect("group hint present");
let unit_at = out.find("systemctl").expect("unit hint present");
assert!(
group_at < unit_at,
"the group fix must precede unit hints, got: {out}"
);
}
#[test]
fn a_missing_socket_does_not_suggest_a_group_change() {
let out = doctor_fail_lines(
"unix:///run/sysknife/daemon.sock",
"No such file or directory (os error 2)",
)
.join("\n");
assert!(
!out.contains("usermod"),
"no group hint when the socket is absent, got: {out}"
);
}
#[test]
fn a_vsock_target_does_not_suggest_local_systemd_at_all() {
let out = doctor_fail_lines("vsock://3:7777", "boom").join("\n");
assert!(out.contains("vsock://3:7777"));
assert!(
!out.contains("systemctl"),
"local unit commands are misleading for a remote target, got: {out}"
);
}
#[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"
);
}
}