harn-cli 0.10.73

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

use serde::Serialize;

use crate::test_runner::{self, AffectedTestFiles};

#[derive(Debug, Clone, Copy, Serialize)]
#[serde(rename_all = "snake_case")]
pub(super) enum AffectedTestMode {
    Selected,
    Full,
}

pub(super) struct ResolvedAffectedTests {
    pub mode: AffectedTestMode,
    pub reason: String,
    pub files: Vec<String>,
}

#[derive(Serialize)]
pub(super) struct AffectedTestPlan<'a> {
    schema_version: u32,
    kind: &'static str,
    base_ref: &'a str,
    mode: AffectedTestMode,
    reason: &'a str,
    requested_targets: &'a [String],
    test_file_count: usize,
    test_files: Vec<String>,
}

impl ResolvedAffectedTests {
    pub fn plan<'a>(&'a self, base: &'a str, paths: &'a [String]) -> AffectedTestPlan<'a> {
        AffectedTestPlan {
            schema_version: 1,
            kind: "harn.test.affected_plan",
            base_ref: base,
            mode: self.mode,
            reason: &self.reason,
            requested_targets: paths,
            test_file_count: self.files.len(),
            test_files: self.files.iter().map(|path| plan_path(path)).collect(),
        }
    }
}

fn plan_path(path: &str) -> String {
    let path = Path::new(path);
    let display = std::env::current_dir()
        .ok()
        .and_then(|current| path.strip_prefix(current).ok())
        .unwrap_or(path);
    display
        .to_string_lossy()
        .replace(std::path::MAIN_SEPARATOR, "/")
}

pub(super) fn resolve(paths: &[String], base: &str) -> ResolvedAffectedTests {
    let targets = paths.iter().map(PathBuf::from).collect::<Vec<_>>();
    let changed = match changed_harn_files(base) {
        Ok(changed) => changed,
        Err(reason) => {
            return resolved(AffectedTestMode::Full, reason, all_test_files(&targets));
        }
    };

    match test_runner::select_affected_test_files(&targets, &changed) {
        AffectedTestFiles::Selected { files } => resolved(
            AffectedTestMode::Selected,
            "resolved changed modules through the transitive importer graph".to_string(),
            files,
        ),
        AffectedTestFiles::Full { files, reason } => {
            resolved(AffectedTestMode::Full, reason, files)
        }
    }
}

fn resolved(mode: AffectedTestMode, reason: String, files: Vec<PathBuf>) -> ResolvedAffectedTests {
    ResolvedAffectedTests {
        mode,
        reason,
        files: files
            .into_iter()
            .map(|path| path.to_string_lossy().into_owned())
            .collect(),
    }
}

fn all_test_files(targets: &[PathBuf]) -> Vec<PathBuf> {
    test_runner::discover_test_files_for_targets(targets)
}

fn changed_harn_files(base: &str) -> Result<Vec<PathBuf>, String> {
    let root = git_stdout(&["rev-parse", "--show-toplevel"])?;
    let root = PathBuf::from(root.trim());
    let range = format!("{base}...HEAD");
    let output = Command::new("git")
        .args([
            "diff",
            "--name-status",
            "-z",
            "--find-renames",
            &range,
            "--",
        ])
        .output()
        .map_err(|error| format!("could not inspect Git changes: {error}"))?;
    if !output.status.success() {
        return Err(format!(
            "git diff {range} failed: {}",
            String::from_utf8_lossy(&output.stderr).trim()
        ));
    }

    let fields = output
        .stdout
        .split(|byte| *byte == 0)
        .filter(|field| !field.is_empty())
        .map(|field| String::from_utf8_lossy(field).into_owned())
        .collect::<Vec<_>>();
    let mut changed = Vec::new();
    let mut index = 0;
    while index < fields.len() {
        let status = &fields[index];
        index += 1;
        let Some(kind) = status.chars().next() else {
            return Err("git diff returned an empty change status".to_string());
        };
        if matches!(kind, 'R' | 'C') {
            return Err(format!(
                "{status} change requires the complete suite because module identity changed"
            ));
        }
        let Some(relative) = fields.get(index) else {
            return Err(format!("git diff omitted the path for status {status}"));
        };
        index += 1;
        if kind == 'D' {
            return Err(format!(
                "deleted module {relative} requires the complete suite"
            ));
        }
        if !matches!(kind, 'A' | 'M' | 'T' | 'U' | 'X' | 'B') {
            return Err(format!("unrecognized Git change status {status}"));
        }
        if Path::new(relative)
            .extension()
            .and_then(|value| value.to_str())
            != Some("harn")
        {
            return Err(format!(
                "changed non-Harn input {relative} may be read dynamically"
            ));
        }
        changed.push(root.join(relative));
    }
    changed.sort();
    changed.dedup();
    Ok(changed)
}

fn git_stdout(args: &[&str]) -> Result<String, String> {
    let output = Command::new("git")
        .args(args)
        .output()
        .map_err(|error| format!("could not run git {}: {error}", args.join(" ")))?;
    if output.status.success() {
        Ok(String::from_utf8_lossy(&output.stdout).into_owned())
    } else {
        Err(format!(
            "git {} failed: {}",
            args.join(" "),
            String::from_utf8_lossy(&output.stderr).trim()
        ))
    }
}