harn-cli 0.8.67

CLI for the Harn programming language — run, test, REPL, format, and lint
//! `harn rule test` — run each rule's inline-annotation tests.
//!
//! A rule `foo.toml` pairs with a fixture `foo.<ext>` (the language's primary
//! extension) carrying `// ruleid:` / `// ok:` annotations; the harness
//! (`harn_rules::run_inline_test`) checks that the rule's matches line up.

use crate::cli::RuleArgs;

#[cfg(not(feature = "hostlib"))]
pub(crate) async fn run(_args: RuleArgs) {
    eprintln!(
        "`harn rule` requires the `hostlib` feature (default-on); it is unavailable in this build"
    );
    std::process::exit(2);
}

#[cfg(feature = "hostlib")]
pub(crate) async fn run(args: RuleArgs) {
    match args.command {
        crate::cli::RuleCommand::Publish(publish) => run_publish(publish),
        crate::cli::RuleCommand::Search(search) => run_search(search),
        crate::cli::RuleCommand::Test(test) => run_test(test),
    }
}

#[cfg(feature = "hostlib")]
fn run_publish(args: crate::cli::PublishArgs) {
    crate::package::publish_rule_package(
        args.package.as_deref(),
        args.dry_run,
        &args.remote,
        &args.index_repo,
        &args.index_path,
        args.registry_name.as_deref(),
        args.skip_index_pr,
        args.registry.as_deref(),
        args.json,
    );
}

#[cfg(feature = "hostlib")]
fn run_search(args: crate::cli::PackageSearchArgs) {
    crate::package::search_rule_package_registry(
        args.query.as_deref(),
        args.registry.as_deref(),
        args.json,
    );
}

#[cfg(feature = "hostlib")]
fn run_test(args: crate::cli::RuleTestArgs) {
    use std::fs;
    use std::path::{Path, PathBuf};

    use harn_rules::{load_rule_file, run_inline_test, CompiledRule, InlineTestReport};

    // An explicit path wins; otherwise discover the project's `[rules]
    // ruleDirs` (#2843), falling back to the current directory. This makes
    // `harn rule test` a zero-config CI gate for a project's rule pack.
    let (rule_files, source): (Vec<PathBuf>, String) = match args.path.as_deref() {
        Some(path) => (discover_rule_files(Path::new(path)), path.to_string()),
        None => match project_rule_dirs() {
            Some(dirs) => (
                dirs.iter().flat_map(|d| discover_rule_files(d)).collect(),
                "[rules] ruleDirs".to_string(),
            ),
            None => (discover_rule_files(Path::new(".")), ".".to_string()),
        },
    };
    if rule_files.is_empty() {
        eprintln!("rule test: no `*.toml` rules found under `{source}`");
        std::process::exit(2);
    }
    // A failed load of an explicitly-named rule file is a hard error; a stray
    // non-rule `*.toml` swept from a directory is silently skipped.
    let explicit_file = args.path.as_deref().is_some_and(|p| Path::new(p).is_file());

    struct Case {
        fixture: PathBuf,
        report: InlineTestReport,
    }
    let mut cases: Vec<Case> = Vec::new();
    let mut missing_fixtures: Vec<PathBuf> = Vec::new();
    let mut errors: Vec<String> = Vec::new();

    for rule_path in rule_files {
        let rule = match load_rule_file(&rule_path) {
            Ok(rule) => rule,
            // A non-rule `*.toml` in a scanned directory is silently skipped;
            // an explicit file argument that fails is a hard error.
            Err(err) => {
                if explicit_file {
                    errors.push(format!("{}: {err}", rule_path.display()));
                }
                continue;
            }
        };
        let compiled = match CompiledRule::compile(&rule) {
            Ok(c) => c,
            Err(err) => {
                errors.push(format!("{}: {err}", rule_path.display()));
                continue;
            }
        };
        let fixture = rule_path.with_extension(compiled.language().primary_extension());
        let Ok(source) = fs::read_to_string(&fixture) else {
            missing_fixtures.push(fixture);
            continue;
        };
        match run_inline_test(&compiled, &source) {
            Ok(report) => cases.push(Case { fixture, report }),
            Err(err) => errors.push(format!("{}: {err}", fixture.display())),
        }
    }

    let failed = cases.iter().filter(|c| !c.report.passed).count();

    if args.json {
        let cases_json: Vec<serde_json::Value> = cases
            .iter()
            .map(|c| {
                serde_json::json!({
                    "fixture": c.fixture.display().to_string(),
                    "rule_id": c.report.rule_id,
                    "passed": c.report.passed,
                    "matches": c.report.matches,
                    "checked": c.report.checked,
                    "failures": c.report.failures.iter().map(|f| f.describe()).collect::<Vec<_>>(),
                })
            })
            .collect();
        let envelope = serde_json::json!({
            "schemaVersion": 1,
            "passed": failed == 0 && errors.is_empty(),
            "cases": cases_json,
            "missing_fixtures": missing_fixtures.iter().map(|p| p.display().to_string()).collect::<Vec<_>>(),
            "errors": errors,
        });
        println!("{}", serde_json::to_string_pretty(&envelope).unwrap());
    } else {
        for case in &cases {
            let tag = if case.report.passed { "PASS" } else { "FAIL" };
            println!(
                "{tag}  {} ({})",
                case.fixture.display(),
                case.report.rule_id
            );
            for failure in &case.report.failures {
                println!("        {}", failure.describe());
            }
        }
        for path in &missing_fixtures {
            println!("SKIP  {} (no fixture)", path.display());
        }
        for err in &errors {
            eprintln!("error: {err}");
        }
        println!(
            "{} passed, {failed} failed, {} skipped",
            cases.len() - failed,
            missing_fixtures.len()
        );
    }

    if failed > 0 || !errors.is_empty() {
        std::process::exit(1);
    }
}

/// Collect rule `*.toml` files: the path itself if it is a file, else every
/// `*.toml` under it (gitignore-aware).
#[cfg(feature = "hostlib")]
fn discover_rule_files(root: &std::path::Path) -> Vec<std::path::PathBuf> {
    use ignore::WalkBuilder;

    if root.is_file() {
        return vec![root.to_path_buf()];
    }
    let mut out = Vec::new();
    let mut walker = WalkBuilder::new(root);
    walker.git_ignore(true).require_git(false);
    for entry in walker.build().filter_map(Result::ok) {
        let path = entry.path();
        if entry.file_type().is_some_and(|t| t.is_file())
            && path.extension().and_then(|e| e.to_str()) == Some("toml")
        {
            out.push(path.to_path_buf());
        }
    }
    out.sort();
    out
}

/// The project's `[rules] ruleDirs` (resolved relative to the manifest dir),
/// or `None` when there is no manifest or it declares no `ruleDirs`.
#[cfg(feature = "hostlib")]
fn project_rule_dirs() -> Option<Vec<std::path::PathBuf>> {
    let cwd = std::env::current_dir().ok()?;
    let (manifest, dir) = crate::package::find_nearest_manifest(&cwd)?;
    if manifest.rules.rule_dirs.is_empty() {
        return None;
    }
    Some(
        manifest
            .rules
            .rule_dirs
            .iter()
            .map(|rel| dir.join(rel))
            .collect(),
    )
}