whyno-cli 0.2.0

Linux permission debugger
//! Default human-readable checklist output.
//!
//! Renders `[PASS]`/`[FAIL]`/`[SKIP]` with ANSI color per layer.
//! Fixes indented below `[FAIL]` lines. color respects `NO_COLOR`
//! Env and `--no-color` via `owo_colors::set_override()`.

use std::io::Write;

use owo_colors::{OwoColorize, Stream};

use whyno_core::checks::{CheckReport, CoreLayer, LayerResult, MacLayer};
use whyno_core::fix::{commands, scoring, Fix, FixPlan};
use whyno_core::state::{Probe, SystemState};

use super::layer_name;
use crate::error::OutputError;

/// Renders check report as human-readable checklist.
pub fn render(
    report: &CheckReport,
    plan: &FixPlan,
    state: &SystemState,
    writer: &mut dyn Write,
) -> Result<(), OutputError> {
    write_header(state, writer)?;
    writeln!(writer)?;

    for (layer, result) in &report.core_results {
        write_layer_line(layer, result, writer)?;
        write_fixes_for_layer(layer, plan, writer)?;
    }
    for (layer, result) in &report.mac_results {
        write_mac_layer_line(*layer, result, writer)?;
    }

    write_warnings(&plan.warnings, writer)?;
    Ok(())
}

/// Subject, operation, and target header block.
fn write_header(state: &SystemState, writer: &mut dyn Write) -> Result<(), OutputError> {
    let s = &state.subject;
    let groups = format_groups(&s.groups);
    let caps_suffix = match s.capabilities {
        Probe::Known(bitmask) => format!(", caps={bitmask:#018x}"),
        _ => String::new(),
    };
    let target_path = state.walk.last().map(|c| c.path.display().to_string());
    let target = target_path.as_deref().unwrap_or("/");
    let op = format!("{:?}", state.operation).to_lowercase();

    writeln!(
        writer,
        "  Subject: uid={}, gid={}, groups=[{}]{}",
        s.uid, s.gid, groups, caps_suffix
    )?;
    writeln!(writer, "  Operation: {op}")?;
    writeln!(writer, "  Target: {target}")?;
    Ok(())
}

/// Formats groups as comma-separated string.
fn format_groups(groups: &[u32]) -> String {
    groups
        .iter()
        .map(ToString::to_string)
        .collect::<Vec<_>>()
        .join(", ")
}

/// `[PASS]`/`[FAIL]`/`[SKIP]` line for a core layer result.
fn write_layer_line(
    layer: CoreLayer,
    result: &LayerResult,
    writer: &mut dyn Write,
) -> Result<(), OutputError> {
    let name = layer_name::padded(layer);
    write_result_line(name, result, writer)
}

/// `[PASS]`/`[FAIL]`/`[SKIP]` line for a MAC layer result.
fn write_mac_layer_line(
    layer: MacLayer,
    result: &LayerResult,
    writer: &mut dyn Write,
) -> Result<(), OutputError> {
    let name = layer_name::padded_mac(layer);
    write_result_line(name, result, writer)
}

/// Formatted result line for a display name and `LayerResult`.
fn write_result_line(
    name: &str,
    result: &LayerResult,
    writer: &mut dyn Write,
) -> Result<(), OutputError> {
    match result {
        LayerResult::Pass { detail, warnings } => {
            let tag = "[PASS]"
                .if_supports_color(Stream::Stdout, |s| s.green())
                .to_string();
            writeln!(writer, "  {tag} {name} -- {detail}")?;
            for warning in warnings {
                let prefix = "\u{26a0}"
                    .if_supports_color(Stream::Stdout, |s| s.yellow())
                    .to_string();
                writeln!(writer, "         {prefix} {warning}")?;
            }
        }
        LayerResult::Fail { detail, .. } => {
            let tag = "[FAIL]"
                .if_supports_color(Stream::Stdout, |s| s.red())
                .to_string();
            writeln!(writer, "  {tag} {name} -- {detail}")?;
        }
        LayerResult::Degraded { reason } => {
            let tag = "[SKIP]"
                .if_supports_color(Stream::Stdout, |s| s.yellow())
                .to_string();
            writeln!(writer, "  {tag} {name} -- {reason}")?;
        }
        _ => {
            writeln!(writer, "  [????] {name} -- unknown result")?;
        }
    }
    Ok(())
}

/// Fix lines indented under a failing layer, ordered by impact.
fn write_fixes_for_layer(
    layer: CoreLayer,
    plan: &FixPlan,
    writer: &mut dyn Write,
) -> Result<(), OutputError> {
    let fixes: Vec<&Fix> = plan.fixes.iter().filter(|f| f.layer == layer).collect();
    for fix in fixes {
        write_fix_line(fix, writer)?;
    }
    Ok(())
}

/// Single fix line with impact score and command.
fn write_fix_line(fix: &Fix, writer: &mut dyn Write) -> Result<(), OutputError> {
    let cmd = commands::render_command(&fix.action);
    let warning = if scoring::needs_warning(fix.impact) {
        " (!)"
    } else {
        ""
    };
    writeln!(
        writer,
        "         Fix: {cmd}  [impact: {}/6]{warning}",
        fix.impact
    )?;
    Ok(())
}

/// Plan-level warnings at output bottom.
fn write_warnings(warnings: &[String], writer: &mut dyn Write) -> Result<(), OutputError> {
    if warnings.is_empty() {
        return Ok(());
    }
    writeln!(writer)?;
    for warning in warnings {
        writeln!(writer, "  Warning: {warning}")?;
    }
    Ok(())
}

#[cfg(test)]
#[path = "checklist_tests.rs"]
mod tests;