harn-cli 0.10.21

CLI for the Harn programming language — run, test, REPL, format, and lint
use std::collections::BTreeSet;
use std::fs;
use std::path::Path;

use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};

pub const CLI_AOT_MANIFEST_SCHEMA_VERSION: u32 = 1;

#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct CliAotManifest {
    pub schema_version: u32,
    pub harn_version: String,
    pub compiler_fingerprint: String,
    pub scripts: Vec<CliAotScriptRecord>,
}

#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct CliAotScriptRecord {
    pub name: String,
    pub source_path: String,
    pub source_sha256: String,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub artifact: Option<CliAotArtifactRecord>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub skip_reason: Option<String>,
}

#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct CliAotArtifactRecord {
    pub path: String,
    pub sha256: String,
}

impl CliAotManifest {
    pub fn validate_shape(&self) -> Result<(), String> {
        if self.schema_version != CLI_AOT_MANIFEST_SCHEMA_VERSION {
            return Err(format!(
                "unsupported schema_version {}; expected {CLI_AOT_MANIFEST_SCHEMA_VERSION}",
                self.schema_version
            ));
        }
        if self.harn_version.trim().is_empty() {
            return Err("harn_version must not be empty".to_string());
        }
        if self.compiler_fingerprint.trim().is_empty() {
            return Err("compiler_fingerprint must not be empty".to_string());
        }

        let mut names = BTreeSet::new();
        let mut source_paths = BTreeSet::new();
        let mut artifact_paths = BTreeSet::new();
        for script in &self.scripts {
            if script.name.trim().is_empty() {
                return Err("script name must not be empty".to_string());
            }
            if !names.insert(script.name.as_str()) {
                return Err(format!("duplicate script name `{}`", script.name));
            }
            if !source_paths.insert(script.source_path.as_str()) {
                return Err(format!("duplicate source path `{}`", script.source_path));
            }
            if script.source_sha256.len() != 64 {
                return Err(format!(
                    "script `{}` has an invalid source_sha256",
                    script.name
                ));
            }
            match (&script.artifact, &script.skip_reason) {
                (Some(artifact), None) => {
                    if artifact.sha256.len() != 64 {
                        return Err(format!(
                            "script `{}` has an invalid artifact sha256",
                            script.name
                        ));
                    }
                    if !artifact_paths.insert(artifact.path.as_str()) {
                        return Err(format!("duplicate artifact path `{}`", artifact.path));
                    }
                }
                (None, Some(reason)) if !reason.trim().is_empty() => {}
                _ => {
                    return Err(format!(
                        "script `{}` must declare exactly one of artifact or non-empty skip_reason",
                        script.name
                    ));
                }
            }
        }
        Ok(())
    }
}

pub fn read_manifest(path: &Path) -> Result<CliAotManifest, String> {
    let bytes = fs::read(path).map_err(|error| format!("read {}: {error}", path.display()))?;
    let manifest: CliAotManifest = serde_json::from_slice(&bytes)
        .map_err(|error| format!("parse {}: {error}", path.display()))?;
    manifest.validate_shape()?;
    Ok(manifest)
}

pub fn canonical_manifest_bytes(manifest: &CliAotManifest) -> Result<Vec<u8>, String> {
    manifest.validate_shape()?;
    let mut bytes = serde_json::to_vec_pretty(manifest)
        .map_err(|error| format!("serialize CLI AOT manifest: {error}"))?;
    bytes.push(b'\n');
    Ok(bytes)
}

pub fn sha256_bytes(bytes: &[u8]) -> String {
    let digest = Sha256::digest(bytes);
    digest.iter().map(|byte| format!("{byte:02x}")).collect()
}

pub fn canonical_source_text(source: &str) -> String {
    String::from_utf8(crate::codegen_fingerprint::canonical_source_bytes(
        source.as_bytes(),
    ))
    .expect("canonical source preserves UTF-8")
}

pub fn sha256_source_bytes(bytes: &[u8]) -> String {
    sha256_bytes(&crate::codegen_fingerprint::canonical_source_bytes(bytes))
}

pub fn sha256_file(path: &Path) -> Result<String, String> {
    let bytes = fs::read(path).map_err(|error| format!("read {}: {error}", path.display()))?;
    Ok(sha256_bytes(&bytes))
}

pub fn sha256_source_file(path: &Path) -> Result<String, String> {
    let bytes = fs::read(path).map_err(|error| format!("read {}: {error}", path.display()))?;
    Ok(sha256_source_bytes(&bytes))
}

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

    fn compiled_script(name: &str) -> CliAotScriptRecord {
        CliAotScriptRecord {
            name: name.to_string(),
            source_path: format!("../harn-stdlib/src/stdlib/cli/{name}.harn"),
            source_sha256: "a".repeat(64),
            artifact: Some(CliAotArtifactRecord {
                path: format!("generated/cli-bytecode/{name}.harnbc"),
                sha256: "b".repeat(64),
            }),
            skip_reason: None,
        }
    }

    fn manifest(scripts: Vec<CliAotScriptRecord>) -> CliAotManifest {
        CliAotManifest {
            schema_version: CLI_AOT_MANIFEST_SCHEMA_VERSION,
            harn_version: "1.2.3".to_string(),
            compiler_fingerprint: "c".repeat(64),
            scripts,
        }
    }

    #[test]
    fn manifest_requires_unique_names_and_paths() {
        let first = compiled_script("doctor");
        let mut duplicate_name = compiled_script("doctor");
        duplicate_name.source_path = "different.harn".to_string();
        duplicate_name.artifact.as_mut().expect("artifact").path = "different.harnbc".to_string();
        assert!(manifest(vec![first.clone(), duplicate_name])
            .validate_shape()
            .unwrap_err()
            .contains("duplicate script name"));

        let mut duplicate_artifact = compiled_script("echo");
        duplicate_artifact.artifact.as_mut().expect("artifact").path =
            first.artifact.as_ref().expect("artifact").path.clone();
        assert!(manifest(vec![first, duplicate_artifact])
            .validate_shape()
            .unwrap_err()
            .contains("duplicate artifact path"));
    }

    #[test]
    fn manifest_requires_exactly_one_artifact_or_skip_reason() {
        let mut neither = compiled_script("doctor");
        neither.artifact = None;
        assert!(manifest(vec![neither]).validate_shape().is_err());

        let mut both = compiled_script("doctor");
        both.skip_reason = Some("runtime only".to_string());
        assert!(manifest(vec![both]).validate_shape().is_err());

        let skipped = CliAotScriptRecord {
            name: "codemod".to_string(),
            source_path: "../harn-stdlib/src/stdlib/cli/codemod.harn".to_string(),
            source_sha256: "a".repeat(64),
            artifact: None,
            skip_reason: Some("runtime only".to_string()),
        };
        assert!(manifest(vec![skipped]).validate_shape().is_ok());
    }

    #[test]
    fn canonical_manifest_serialization_is_stable_and_newline_terminated() {
        let value = manifest(vec![compiled_script("doctor")]);
        let first = canonical_manifest_bytes(&value).expect("serialize manifest");
        let second = canonical_manifest_bytes(&value).expect("serialize manifest again");
        assert_eq!(first, second);
        assert_eq!(first.last(), Some(&b'\n'));
    }

    #[test]
    fn source_hash_is_line_ending_stable() {
        assert_eq!(
            sha256_source_bytes(b"fn main() {\n  return\n}\n"),
            sha256_source_bytes(b"fn main() {\r\n  return\r\n}\r\n")
        );
    }

    #[test]
    fn source_file_hash_is_line_ending_stable() {
        let dir =
            std::env::temp_dir().join(format!("harn-cli-aot-manifest-test-{}", std::process::id()));
        let _ = fs::remove_dir_all(&dir);
        fs::create_dir_all(&dir).expect("create temp fixture dir");
        let lf = dir.join("lf.harn");
        let crlf = dir.join("crlf.harn");
        fs::write(&lf, b"const value = 1\n").expect("write lf");
        fs::write(&crlf, b"const value = 1\r\n").expect("write crlf");

        assert_eq!(
            sha256_source_file(&lf).expect("hash lf"),
            sha256_source_file(&crlf).expect("hash crlf")
        );
        let _ = fs::remove_dir_all(&dir);
    }
}