harn-cli 0.10.49

CLI for the Harn programming language — run, test, REPL, format, and lint
Documentation
use std::collections::{BTreeMap, BTreeSet};
use std::path::PathBuf;
use std::process;

use serde_json::json;

use harn_builtin_meta::{
    BuiltinContract, BuiltinExposure, EffectAccess, EffectKind, ResourceSelector,
};

use crate::cli::{
    ContractsArgs, ContractsBundleArgs, ContractsCommand, ContractsHostCapabilitiesArgs,
    ContractsOutputArgs,
};
use crate::commands::check;
use crate::package::{self, CheckConfig};

fn print_json(value: &serde_json::Value, pretty: bool) {
    let output = if pretty {
        serde_json::to_string_pretty(value)
    } else {
        serde_json::to_string(value)
    }
    .unwrap_or_else(|error| {
        eprintln!("Failed to serialize JSON output: {error}");
        process::exit(1);
    });
    println!("{output}");
}

fn effective_config_for_targets(
    targets: &[PathBuf],
    host_capabilities: Option<&String>,
    bundle_root: Option<&String>,
) -> CheckConfig {
    let mut config = targets
        .first()
        .map(|path| package::load_check_config(Some(path)))
        .unwrap_or_default();
    if let Some(path) = host_capabilities {
        config.host_capabilities_path = Some(path.clone());
    }
    if let Some(path) = bundle_root {
        config.bundle_root = Some(path.clone());
    }
    config
}

fn builtin_contract_value(_args: &ContractsOutputArgs) -> serde_json::Value {
    let runtime: BTreeSet<String> = harn_vm::stdlib::stdlib_builtin_names()
        .into_iter()
        .collect();
    let parser = harn_parser::known_builtin_metadata()
        .map(|entry| {
            (
                entry.name.to_string(),
                entry
                    .return_types
                    .iter()
                    .map(|value| value.to_string())
                    .collect::<Vec<_>>(),
            )
        })
        .collect::<BTreeMap<_, _>>();
    let contracts = harn_vm::stdlib::all_builtin_manifest()
        .iter()
        .map(|entry| (entry.name.to_string(), entry.contract))
        .collect::<BTreeMap<_, _>>();
    let mut rows = Vec::new();
    let names = runtime
        .iter()
        .cloned()
        .chain(parser.keys().cloned())
        .chain(contracts.keys().cloned())
        .collect::<BTreeSet<_>>();
    for name in names {
        let parser_known = parser.contains_key(&name);
        let runtime_registered = runtime.contains(&name);
        let contract = contracts.get(&name).copied();
        let alignment_status = match (parser_known, runtime_registered, contract.is_some()) {
            (true, true, _) => "matched",
            (true, false, _) => "parser_only",
            (false, true, _) => "runtime_only",
            (false, false, true) => "contract_only",
            (false, false, false) => unreachable!(),
        };
        rows.push(json!({
            "name": name,
            "parser_known": parser_known,
            "runtime_registered": runtime_registered,
            "contract_declared": contract.is_some(),
            "return_types": parser.get(&name).cloned().unwrap_or_default(),
            "alignment_status": alignment_status,
            "contract": contract.map(contract_json),
        }));
    }
    json!({
        "version": 2,
        "builtins": rows,
    })
}

fn contract_json(contract: BuiltinContract) -> serde_json::Value {
    json!({
        "exposure": exposure_json(contract.exposure),
        "effects": contract.effects.iter().map(effect_json).collect::<Vec<_>>(),
    })
}

fn exposure_json(exposure: BuiltinExposure) -> serde_json::Value {
    match exposure {
        BuiltinExposure::Undeclared => json!({"kind": "undeclared"}),
        BuiltinExposure::PureGlobal => json!({"kind": "pure_global"}),
        BuiltinExposure::CapabilityFunction { authority_argument } => json!({
            "kind": "capability_function",
            "authority_argument": authority_argument,
        }),
        BuiltinExposure::HarnessMethod { capability, method } => json!({
            "kind": "harness_method",
            "capability": capability.field_name(),
            "handle_type": capability.type_name(),
            "method": method,
            "source_name": format!("harness.{}.{}", capability.field_name(), method),
        }),
        BuiltinExposure::PrivilegedWire => json!({"kind": "privileged_wire"}),
        BuiltinExposure::RuntimeInternal => json!({"kind": "runtime_internal"}),
    }
}

fn effect_json(effect: &harn_builtin_meta::EffectSpec) -> serde_json::Value {
    json!({
        "kind": effect_kind_name(effect.kind),
        "access": effect_access_name(effect.access),
        "resources": effect.resources.iter().map(resource_selector_json).collect::<Vec<_>>(),
    })
}

fn effect_kind_name(kind: EffectKind) -> &'static str {
    match kind {
        EffectKind::Stdio => "stdio",
        EffectKind::Fs => "fs",
        EffectKind::Env => "env",
        EffectKind::Clock => "clock",
        EffectKind::Random => "random",
        EffectKind::Network => "network",
        EffectKind::Process => "process",
        EffectKind::Llm => "llm",
        EffectKind::Tool => "tool",
        EffectKind::Mcp => "mcp",
        EffectKind::Host => "host",
        EffectKind::Worker => "worker",
        EffectKind::Secret => "secret",
        EffectKind::Observability => "observability",
        EffectKind::Channel => "channel",
        EffectKind::State => "state",
    }
}

fn effect_access_name(access: EffectAccess) -> &'static str {
    match access {
        EffectAccess::Read => "read",
        EffectAccess::Write => "write",
        EffectAccess::Mutate => "mutate",
        EffectAccess::Observe => "observe",
    }
}

fn resource_selector_json(selector: &ResourceSelector) -> serde_json::Value {
    match selector {
        ResourceSelector::Argument(argument) => {
            json!({"kind": "argument", "argument": argument})
        }
        ResourceSelector::Field { argument, path } => json!({
            "kind": "field",
            "argument": argument,
            "path": path,
        }),
        ResourceSelector::EachArgument(argument) => {
            json!({"kind": "each_argument", "argument": argument})
        }
        ResourceSelector::Constant(value) => {
            json!({"kind": "constant", "value": value})
        }
        ResourceSelector::Dynamic => json!({"kind": "dynamic"}),
    }
}

fn host_capabilities_value(args: &ContractsHostCapabilitiesArgs) -> serde_json::Value {
    let mut config = CheckConfig::default();
    if let Some(path) = args.host_capabilities.as_ref() {
        config.host_capabilities_path = Some(path.clone());
    }
    let capabilities = check::load_host_capabilities(&config);
    let sorted = capabilities
        .into_iter()
        .map(|(capability, ops)| {
            let mut ops = ops.into_iter().collect::<Vec<_>>();
            ops.sort();
            (capability, ops)
        })
        .collect::<BTreeMap<_, _>>();
    json!({
        "version": 1,
        "capabilities": sorted,
    })
}

fn bundle_contract_value(args: &ContractsBundleArgs) -> (serde_json::Value, bool) {
    let targets: Vec<&str> = args.targets.iter().map(String::as_str).collect();
    let files = check::collect_harn_targets(&targets);
    if files.is_empty() {
        eprintln!("No .harn files found");
        process::exit(1);
    }

    let config = effective_config_for_targets(
        &files,
        args.host_capabilities.as_ref(),
        args.bundle_root.as_ref(),
    );

    let mut failed = false;
    if args.verify {
        let module_graph = check::build_module_graph(&files);
        let cross_file_imports = check::collect_cross_file_imports(&module_graph);
        let mut analysis = harn_parser::analysis::AnalysisDatabase::new();
        for file in &files {
            let mut file_config = package::load_check_config(Some(file));
            if let Some(path) = args.host_capabilities.as_ref() {
                file_config.host_capabilities_path = Some(path.clone());
            }
            if let Some(path) = args.bundle_root.as_ref() {
                file_config.bundle_root = Some(path.clone());
            }
            let outcome = check::check_file_inner(
                &mut analysis,
                file,
                &file_config,
                &cross_file_imports,
                &module_graph,
                false,
            );
            failed |= outcome.should_fail(file_config.strict);
        }
    }

    let value = check::build_bundle_manifest(&files, &config);
    (value, failed)
}

pub(crate) async fn handle_contracts_command(args: ContractsArgs) {
    match args.command {
        ContractsCommand::Builtins(args) => {
            print_json(&builtin_contract_value(&args), args.pretty);
        }
        ContractsCommand::HostCapabilities(args) => {
            print_json(&host_capabilities_value(&args), args.pretty);
        }
        ContractsCommand::Bundle(args) => {
            let (value, failed) = bundle_contract_value(&args);
            print_json(&value, args.pretty);
            if failed {
                process::exit(1);
            }
        }
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn builtin_export_projects_typed_exposure_and_effect_contracts() {
        harn_vm::stdlib::force_link();
        let value = builtin_contract_value(&ContractsOutputArgs { pretty: false });
        assert_eq!(value["version"], 2);

        let builtins = value["builtins"].as_array().expect("builtin rows");
        let fs_read = builtins
            .iter()
            .find(|row| row["name"] == "__cap_fs_read_text")
            .expect("filesystem capability contract");
        assert_eq!(fs_read["alignment_status"], "contract_only");
        assert_eq!(fs_read["contract"]["exposure"]["kind"], "harness_method");
        assert_eq!(
            fs_read["contract"]["exposure"]["source_name"],
            "harness.fs.read_text"
        );
        assert_eq!(fs_read["contract"]["effects"][0]["kind"], "fs");
        assert_eq!(fs_read["contract"]["effects"][0]["access"], "read");
        assert_eq!(
            fs_read["contract"]["effects"][0]["resources"][0]["kind"],
            "argument"
        );

        let pure = builtins
            .iter()
            .find(|row| row["contract"]["exposure"]["kind"] == "pure_global")
            .expect("at least one pure builtin");
        assert_eq!(pure["contract"]["effects"], json!([]));
    }
}