use std::time::Duration;
use indicatif::{ProgressBar, ProgressStyle};
use owo_colors::{OwoColorize, Stream};
use sysknife_brain::planner::{AuthorizedPlan, PlanRiskLevel};
use sysknife_brain::prefs::PrefChangeKind;
use sysknife_types::{JobState, PreviewEnvelope, ResultEnvelope};
use crate::operator_text::{operator_safe, operator_safe_block, OperatorBlock};
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}")
}
#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
pub struct Omissions {
pub withheld_lines: usize,
pub shortened_lines: usize,
}
impl Omissions {
pub fn is_none(&self) -> bool {
self.withheld_lines == 0 && self.shortened_lines == 0
}
fn record(&mut self, block: &OperatorBlock) {
self.withheld_lines += block.withheld_lines();
self.shortened_lines += block.shortened_lines();
}
}
pub fn print_plan(plan: &AuthorizedPlan, log: &Logger) -> Omissions {
let mut omitted = Omissions::default();
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())
));
if let Some(block) = json_block(step.params()) {
log.println(" params:");
log.println(&indent(block.text(), " "));
omitted.record(&block);
}
}
let changes = plan.pref_changes();
if !changes.is_empty() {
log.println("");
log.println(" Preference changes (saved only if you confirm them below):");
for change in changes {
let verb = match change.kind() {
PrefChangeKind::Remember => "remember",
PrefChangeKind::Forget => "forget",
};
let block = operator_safe_block(&change.fact().replace(['\n', '\r'], " "));
log.println(&format!(" {verb}: {}", block.text()));
omitted.record(&block);
}
}
log.println("");
omitted
}
fn json_block(value: &serde_json::Value) -> Option<OperatorBlock> {
let empty = match value {
serde_json::Value::Null => true,
serde_json::Value::Object(map) => map.is_empty(),
serde_json::Value::Array(items) => items.is_empty(),
_ => false,
};
if empty {
return None;
}
let pretty = serde_json::to_string_pretty(value).unwrap_or_else(|_| "<unavailable>".into());
Some(operator_safe_block(&pretty))
}
fn indent(text: &str, prefix: &str) -> String {
text.lines()
.map(|line| format!("{prefix}{line}"))
.collect::<Vec<_>>()
.join("\n")
}
pub fn print_step_header(action: &str, preview: &PreviewEnvelope) -> Omissions {
let (text, omitted) = step_header_text(action, preview);
eprintln!("{text}");
omitted
}
fn step_header_text(action: &str, preview: &PreviewEnvelope) -> (String, Omissions) {
let mut omitted = Omissions::default();
let mut lines = vec![format!(
"\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 {
lines.push(format!(
" {} reboot required after this step",
"⚠".if_supports_color(Stream::Stderr, |t| t.yellow())
));
}
for w in &preview.warnings {
lines.push(format!(
" {} {}",
"!".if_supports_color(Stream::Stderr, |t| t.yellow()),
operator_safe(w),
));
}
if let Some(block) = json_block(&preview.proposed_change) {
lines.push(" proposed change:".into());
lines.push(indent(block.text(), " "));
omitted.record(&block);
}
if !preview.expected_side_effects.is_empty() {
let effects: Vec<String> = preview
.expected_side_effects
.iter()
.map(|e| format!("- {}", e.replace(['\n', '\r'], " ")))
.collect();
let block = operator_safe_block(&effects.join("\n"));
lines.push(" side effects:".into());
lines.push(indent(block.text(), " "));
omitted.record(&block);
}
(lines.join("\n"), omitted)
}
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"
);
}
fn one_step_plan(params: serde_json::Value) -> AuthorizedPlan {
use sysknife_brain::action_name::ActionName;
use sysknife_brain::planner::{Plan, PlanStep};
Plan::new(
"intent".into(),
"plan summary".into(),
"explanation".into(),
vec![PlanStep::new(
ActionName::parse("AddUserToGroup").unwrap(),
"add a user to a group".into(),
PlanRiskLevel::Medium,
params,
)
.unwrap()],
)
.unwrap()
.into_authorized(|_| PlanRiskLevel::Medium)
}
fn render_plan_to_string(plan: &AuthorizedPlan) -> (String, Omissions) {
let dir = tempfile::tempdir().expect("tempdir");
let path = dir.path().join("plan.log");
let log = Logger::new(Some(&path)).expect("logger");
let omitted = print_plan(plan, &log);
(std::fs::read_to_string(&path).expect("logger tee"), omitted)
}
fn preview_with(proposed_change: serde_json::Value, side_effects: &[&str]) -> PreviewEnvelope {
PreviewEnvelope {
summary: "AddUserToGroup (medium risk)".into(),
risk_level: sysknife_types::RiskLevel::Medium,
current_state: serde_json::json!({}),
proposed_change,
expected_side_effects: side_effects.iter().map(|s| s.to_string()).collect(),
reboot_required: false,
rollback_available: false,
warnings: Vec::new(),
request_hash: sysknife_types::RequestHash::new("hash"),
}
}
#[test]
fn the_plan_shows_each_steps_parameters_neutralised() {
let plan = one_step_plan(serde_json::json!({
"user": "alice\u{202e}nimda",
"group": "wheel\u{e0041}",
}));
let (out, omitted) = render_plan_to_string(&plan);
assert!(out.contains("\"user\""), "parameter names missing:\n{out}");
assert!(
out.contains("alicenimda"),
"parameter value missing:\n{out}"
);
assert!(out.contains("\"wheel\""), "parameter value missing:\n{out}");
assert!(
!out.contains('\u{202e}') && !out.contains('\u{e0041}'),
"invisible characters reached the terminal:\n{out:?}"
);
assert!(omitted.is_none(), "a short block must count as complete");
}
#[test]
fn a_parameter_block_too_tall_to_show_is_counted() {
let many: serde_json::Map<String, serde_json::Value> = (0..60)
.map(|i| (format!("k{i:02}"), serde_json::json!(i)))
.collect();
let (_, omitted) = render_plan_to_string(&one_step_plan(serde_json::Value::Object(many)));
assert!(
omitted.withheld_lines > 0,
"a 60-key parameter block cannot fit in 40 lines: {omitted:?}"
);
}
#[test]
fn the_step_header_shows_the_proposed_change_and_side_effects() {
let preview = preview_with(
serde_json::json!({"add_user": "mallory\u{202e}", "to_group": "sudo"}),
&[
"\x1b[2K\rgrants root via sudo",
"user must log in again\u{e0041}",
],
);
let (out, omitted) = step_header_text("AddUserToGroup", &preview);
assert!(
out.contains("\"add_user\""),
"proposed change missing:\n{out}"
);
assert!(
out.contains("\"mallory\""),
"proposed change missing:\n{out}"
);
assert!(
out.contains("grants root via sudo"),
"side effect missing:\n{out}"
);
assert!(
out.contains("user must log in again"),
"side effect missing:\n{out}"
);
assert!(
!out.contains('\x1b')
&& !out.contains('\r')
&& !out.contains('\u{202e}')
&& !out.contains('\u{e0041}'),
"control or invisible characters reached the terminal:\n{out:?}"
);
assert!(omitted.is_none(), "{omitted:?}");
}
#[test]
fn a_proposed_change_too_tall_or_wide_to_show_is_counted() {
let tall: Vec<u32> = (0..60).collect();
let (_, omitted) = step_header_text("X", &preview_with(serde_json::json!(tall), &[]));
assert!(omitted.withheld_lines > 0, "{omitted:?}");
let wide = "x".repeat(2000);
let (_, omitted) = step_header_text("X", &preview_with(serde_json::json!({}), &[&wide]));
assert!(
omitted.shortened_lines > 0,
"a cut side effect is unseen text: {omitted:?}"
);
}
#[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"
);
}
}