pidgin-cli 0.2.1

CLI for the Pidgin agent handoff protocol
use std::fs;
use std::path::PathBuf;

use pidgin_lang::parser::parse_packet;
use pidgin_lang::resolver::{ResolutionStatus, ResolverContext, resolve_all};
use pidgin_lang::safety::{check_resolved_refs_safety, check_safety};
use pidgin_lang::validator::schema::validate_schema;
use pidgin_lang::validator::syntax::validate_syntax;

use super::{canonicalize_host, get_required_inputs, load_pipeline_configs};

pub fn run(file: PathBuf, host: PathBuf, json: bool) {
    let cfg = load_pipeline_configs(&host);
    let host_root = canonicalize_host(&host);
    let content = match fs::read_to_string(&file) {
        Ok(c) => c,
        Err(e) => {
            eprintln!("{}: FAIL (read error: {})", file.display(), e);
            std::process::exit(1);
        }
    };
    let packet = match parse_packet(&content) {
        Ok(p) => p,
        Err(e) => {
            eprintln!("{}: FAIL (parse error: {})", file.display(), e);
            std::process::exit(1);
        }
    };

    let syntax_errors = validate_syntax(&packet);
    let schema_errors = validate_schema(&packet, &cfg.workflows);
    let mut all_errors: Vec<String> = Vec::new();
    for err in &syntax_errors {
        all_errors.push(format!("[{}] {}", err.code, err.message));
    }
    for err in &schema_errors {
        all_errors.push(format!("[{}] {}", err.code, err.message));
    }

    let safety_result = check_safety(&packet, &cfg.actions, &cfg.safety_rules, &cfg.workflows);
    for rule in &safety_result.fired_rules {
        all_errors.push(format!("[{}] (safety)", rule));
    }

    let required_inputs = get_required_inputs(&packet, &cfg.workflows);

    let ctx = ResolverContext {
        host_root,
        aliases: cfg.aliases,
        required_inputs,
    };
    let resolved = resolve_all(&packet, &ctx);
    let resolved_fired = check_resolved_refs_safety(&resolved, &cfg.safety_rules.private_paths);
    for rule in &resolved_fired {
        all_errors.push(format!("[{}] (safety after resolution)", rule));
    }
    for r in &resolved {
        if r.required
            && matches!(
                r.status,
                ResolutionStatus::Unresolved | ResolutionStatus::Forbidden
            )
        {
            let label = match r.status {
                ResolutionStatus::Unresolved => "UNRESOLVED",
                ResolutionStatus::Forbidden => "FORBIDDEN",
                _ => "ERROR",
            };
            all_errors.push(format!("[{}] {} (required)", label, r.original));
        }
    }

    if json {
        let output = serde_json::json!({
            "file": file.display().to_string(),
            "valid": all_errors.is_empty(),
            "errors": all_errors,
            "safety": {
                "allowed": safety_result.allowed,
                "blocked": safety_result.blocked,
                "fired_rules": safety_result.fired_rules.iter().map(|r| r.to_string()).collect::<Vec<_>>(),
                "human_required": safety_result.human_required,
                "effective_risk": safety_result.effective_risk,
            },
        });
        println!("{}", serde_json::to_string_pretty(&output).unwrap());
    } else {
        if all_errors.is_empty() {
            println!("{}: PASS", file.display());
        } else {
            println!("{}: FAIL", file.display());
            for err in &all_errors {
                eprintln!(" {}", err);
            }
        }
    }

    if !all_errors.is_empty() {
        let has_validation = !syntax_errors.is_empty() || !schema_errors.is_empty();
        std::process::exit(if has_validation { 1 } else { 2 });
    }
}