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 {
let label = match risk {
PlanRiskLevel::Low => "low"
.if_supports_color(Stream::Stdout, |t| t.green())
.to_string(),
PlanRiskLevel::Medium => "medium"
.if_supports_color(Stream::Stdout, |t| t.yellow())
.to_string(),
PlanRiskLevel::High => "HIGH"
.if_supports_color(Stream::Stdout, |t| t.red().bold().to_string())
.to_string(),
};
format!("● {label}")
}
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::Queued => ("○".to_string(), "queued"),
JobState::Running => (
"●"
.if_supports_color(Stream::Stdout, |t| t.cyan())
.to_string(),
"running",
),
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::Canceled => (
"⊘"
.if_supports_color(Stream::Stdout, |t| t.yellow())
.to_string(),
"canceled",
),
JobState::RolledBack => (
"↩"
.if_supports_color(Stream::Stdout, |t| t.cyan())
.to_string(),
"rolled back",
),
JobState::NeedsReboot => (
"↺"
.if_supports_color(Stream::Stdout, |t| t.yellow())
.to_string(),
"needs reboot",
),
};
log.println(&format!(
" {icon} {} — {label}",
operator_safe(&result.summary)
));
if let Some(rollback_ref) = &result.rollback_ref {
log.println(&format!(
" rolled back with: {}",
operator_safe(rollback_ref)
));
}
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) {
let intent = sysknife_brain::prefs::loggable_intent(intent);
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::*;
fn result_with_status(status: JobState) -> ResultEnvelope {
ResultEnvelope {
status,
summary: "test action".to_owned(),
warnings: Vec::new(),
job_id: None,
needs_reboot: false,
rollback_ref: None,
transaction_id: "test-transaction".to_owned(),
}
}
fn render_step_done_to_string(result: &ResultEnvelope) -> String {
let dir = tempfile::tempdir().expect("tempdir");
let path = dir.path().join("step.log");
let log = Logger::new(Some(&path)).expect("logger");
print_step_done(result, &log);
std::fs::read_to_string(&path).expect("logger tee")
}
#[test]
fn print_step_done_renders_every_job_state_explicitly() {
let states = [
(JobState::Queued, "queued"),
(JobState::Running, "running"),
(JobState::Succeeded, "succeeded"),
(JobState::Failed, "failed"),
(JobState::Canceled, "canceled"),
(JobState::RolledBack, "rolled back"),
(JobState::NeedsReboot, "needs reboot"),
];
for (status, label) in states {
let rendered = render_step_done_to_string(&result_with_status(status));
assert!(
rendered.contains(&format!("— {label}")),
"{status:?} must render as {label:?}; got: {rendered:?}"
);
assert!(
!rendered.contains("unknown"),
"{status:?} must never render as unknown; got: {rendered:?}"
);
}
}
#[test]
fn print_step_done_displays_rollback_reference_without_warning_icon() {
let mut result = result_with_status(JobState::RolledBack);
result.rollback_ref = Some("rpm-ostree rollback".to_owned());
let rendered = render_step_done_to_string(&result);
assert!(
rendered.contains("rolled back with: rpm-ostree rollback"),
"rollback output must identify the recovery action; got: {rendered:?}"
);
assert!(
!rendered.contains("unknown"),
"a successful rollback must not be rendered as unknown; got: {rendered:?}"
);
assert!(
!rendered.contains('⚠'),
"a successful rollback must not use the warning icon; got: {rendered:?}"
);
}
#[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 doctor_ok_sample_matches_cli_docs_field_labels_in_order() {
use crate::runner::Logger;
let dir = tempfile::tempdir().expect("tempdir");
let path = dir.path().join("doctor.log");
let log = Logger::new(Some(&path)).expect("logger");
print_doctor_ok(
"unix:///run/sysknife/daemon.sock",
"my-silverblue",
"anthropic",
"claude-sonnet-4-6",
"fedora",
&log,
);
let rendered = std::fs::read_to_string(&path).expect("logger tee");
let labels: Vec<&str> = rendered
.lines()
.filter_map(|line| {
let trimmed = line.trim_start();
trimmed
.strip_prefix("socket ")
.map(|_| "socket")
.or_else(|| trimmed.strip_prefix("host ").map(|_| "host"))
.or_else(|| trimmed.strip_prefix("provider ").map(|_| "provider"))
.or_else(|| trimmed.strip_prefix("model ").map(|_| "model"))
.or_else(|| trimmed.strip_prefix("distro ").map(|_| "distro"))
})
.collect();
assert_eq!(
labels,
["socket", "host", "provider", "model", "distro"],
"doctor field labels must match docs/cli.md sample order; got: {rendered}"
);
let cli_doc = std::fs::read_to_string(
std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../docs/cli.md"),
)
.expect("docs/cli.md");
let sample_start = cli_doc
.find("✓ daemon ok")
.expect("cli.md must include the doctor sample block");
let sample = &cli_doc[sample_start..];
let mut positions = Vec::new();
for label in &labels {
let needle = format!(" {label}");
let pos = sample
.find(&needle)
.unwrap_or_else(|| panic!("docs/cli.md doctor sample must document `{label}`"));
positions.push(pos);
}
assert!(
positions.windows(2).all(|w| w[0] < w[1]),
"docs/cli.md doctor sample field order must match print_doctor_ok"
);
for line in rendered.lines().filter(|l| l.starts_with(" ")) {
assert!(
sample.contains(line),
"docs/cli.md doctor sample must contain the rendered line `{line}`"
);
}
}
#[test]
fn configuration_doc_does_not_claim_doctor_runs_chain_integrity() {
let config_doc = std::fs::read_to_string(
std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../docs/configuration.md"),
)
.expect("docs/configuration.md");
assert!(
!config_doc.to_lowercase().contains("chain-integrity"),
"doctor docs must not claim a chain-integrity check; use `sysknife audit verify` instead"
);
}
#[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"
);
}
}