tirith 0.3.1

Terminal security - catches homograph attacks, pipe-to-shell, ANSI injection
use tirith_core::engine::{self, AnalysisContext};
use tirith_core::extract::ScanContext;
use tirith_core::output;
use tirith_core::tokenize::ShellType;
use tirith_core::verdict::Severity;

pub fn run(url: &str, json: bool) -> i32 {
    let ctx = AnalysisContext {
        input: url.to_string(),
        shell: ShellType::Posix,
        scan_context: ScanContext::Exec,
        raw_bytes: None,
        interactive: true,
        cwd: std::env::current_dir()
            .ok()
            .map(|p| p.display().to_string()),
        file_path: None,
        repo_root: None,
        is_config_override: false,
        clipboard_html: None,
    };

    let verdict = engine::analyze(&ctx);

    if json {
        #[derive(serde::Serialize)]
        struct ScoreOutput<'a> {
            url: &'a str,
            score: u32,
            risk_level: &'a str,
            findings: &'a [tirith_core::verdict::Finding],
        }

        let max_severity = verdict
            .findings
            .iter()
            .map(|f| f.severity)
            .max()
            .unwrap_or(Severity::Info);

        let (score, level) = severity_to_score(max_severity, verdict.findings.len());

        let out = ScoreOutput {
            url,
            score,
            risk_level: level,
            findings: &verdict.findings,
        };
        if serde_json::to_writer_pretty(std::io::stdout().lock(), &out).is_err() {
            eprintln!("tirith: failed to write JSON output");
        }
        println!();
    } else if verdict.findings.is_empty() {
        eprintln!("tirith: {url} — no issues found (score: 0/100)");
    } else {
        let max_severity = verdict
            .findings
            .iter()
            .map(|f| f.severity)
            .max()
            .unwrap_or(Severity::Info);
        let (score, level) = severity_to_score(max_severity, verdict.findings.len());
        eprintln!("tirith: {url} — risk score: {score}/100 ({level})");
        if output::write_human_auto(&verdict, false).is_err() {
            eprintln!("tirith: failed to write output");
        }
    }

    0
}

fn severity_to_score(max: Severity, count: usize) -> (u32, &'static str) {
    let base = match max {
        Severity::Critical => 90,
        Severity::High => 70,
        Severity::Medium => 40,
        Severity::Low => 15,
        Severity::Info => 0,
    };
    let bonus = (count.saturating_sub(1) as u32) * 5;
    let score = (base + bonus).min(100);
    let level = match score {
        0..=20 => "low",
        21..=50 => "medium",
        51..=75 => "high",
        _ => "critical",
    };
    (score, level)
}