harn-cli 0.8.92

CLI for the Harn programming language — run, test, REPL, format, and lint
use super::*;

pub(crate) fn pack_package_impl(
    anchor: Option<&Path>,
    output: Option<&Path>,
    dry_run: bool,
) -> Result<PackagePackReport, PackageError> {
    let report = check_package_impl(anchor)?;
    fail_if_package_errors(&report)?;
    let ctx = load_manifest_context_for_anchor(anchor)?;
    let files = collect_package_files(&ctx.dir)?;
    let artifact_dir = output
        .map(Path::to_path_buf)
        .unwrap_or_else(|| default_artifact_dir(&ctx, &report));

    if !dry_run {
        if artifact_dir.exists() {
            return Err(
                format!("artifact output {} already exists", artifact_dir.display()).into(),
            );
        }
        fs::create_dir_all(&artifact_dir)
            .map_err(|error| format!("failed to create {}: {error}", artifact_dir.display()))?;
        for rel in &files {
            let src = ctx.dir.join(rel);
            let dst = artifact_dir.join(rel);
            if let Some(parent) = dst.parent() {
                fs::create_dir_all(parent)
                    .map_err(|error| format!("failed to create {}: {error}", parent.display()))?;
            }
            fs::copy(&src, &dst)
                .map_err(|error| format!("failed to copy {}: {error}", src.display()))?;
        }
        let manifest_path = artifact_dir.join(".harn-package-manifest.json");
        let manifest_body = serde_json::to_string_pretty(&report)
            .map_err(|error| format!("failed to render package manifest: {error}"))?
            + "\n";
        harn_vm::atomic_io::atomic_write(&manifest_path, manifest_body.as_bytes())
            .map_err(|error| format!("failed to write {}: {error}", manifest_path.display()))?;
    }

    Ok(PackagePackReport {
        package_dir: ctx.dir.display().to_string(),
        artifact_dir: artifact_dir.display().to_string(),
        dry_run,
        files,
        check: report,
    })
}

pub(crate) fn generate_package_docs_impl(
    anchor: Option<&Path>,
    output: Option<&Path>,
    check: bool,
) -> Result<PathBuf, PackageError> {
    let report = check_package_impl(anchor)?;
    let ctx = load_manifest_context_for_anchor(anchor)?;
    let output_path = output
        .map(Path::to_path_buf)
        .unwrap_or_else(|| ctx.dir.join("docs").join("api.md"));
    let rendered = render_package_api_docs(&report);
    if check {
        let existing = fs::read_to_string(&output_path)
            .map_err(|error| format!("failed to read {}: {error}", output_path.display()))?;
        if normalize_newlines(&existing) != normalize_newlines(&rendered) {
            return Err(format!(
                "{} is stale; run `harn package docs`",
                output_path.display()
            )
            .into());
        }
        return Ok(output_path);
    }
    harn_vm::atomic_io::atomic_write(&output_path, rendered.as_bytes())
        .map_err(|error| format!("failed to write {}: {error}", output_path.display()))?;
    Ok(output_path)
}

pub(crate) fn collect_package_files(root: &Path) -> Result<Vec<String>, PackageError> {
    let mut files = Vec::new();
    collect_package_files_inner(root, root, &mut files)?;
    files.sort();
    Ok(files)
}

pub(crate) fn collect_package_files_inner(
    root: &Path,
    dir: &Path,
    out: &mut Vec<String>,
) -> Result<(), PackageError> {
    for entry in
        fs::read_dir(dir).map_err(|error| format!("failed to read {}: {error}", dir.display()))?
    {
        let entry =
            entry.map_err(|error| format!("failed to read {} entry: {error}", dir.display()))?;
        let path = entry.path();
        let file_type = entry
            .file_type()
            .map_err(|error| format!("failed to inspect {}: {error}", path.display()))?;
        if file_type.is_symlink() {
            continue;
        }
        if file_type.is_dir() {
            let rel = path
                .strip_prefix(root)
                .map_err(|error| format!("failed to relativize {}: {error}", path.display()))?;
            if should_skip_package_dir(rel) {
                continue;
            }
            collect_package_files_inner(root, &path, out)?;
        } else if file_type.is_file() {
            let rel = path
                .strip_prefix(root)
                .map_err(|error| format!("failed to relativize {}: {error}", path.display()))?
                .to_string_lossy()
                .replace('\\', "/");
            out.push(rel);
        }
    }
    Ok(())
}

pub(crate) fn should_skip_package_dir(rel: &Path) -> bool {
    if rel == Path::new("docs").join("dist") {
        return true;
    }
    rel.components().any(|component| {
        matches!(
            component.as_os_str().to_str(),
            Some(".git" | ".harn" | "target" | "node_modules")
        )
    })
}

pub(crate) fn default_artifact_dir(ctx: &ManifestContext, report: &PackageCheckReport) -> PathBuf {
    let name = report.name.as_deref().unwrap_or("package");
    let version = report.version.as_deref().unwrap_or("0.0.0");
    ctx.dir
        .join(".harn")
        .join("dist")
        .join(format!("{name}-{version}"))
}

pub(crate) fn fail_if_package_errors(report: &PackageCheckReport) -> Result<(), PackageError> {
    if report.errors.is_empty() {
        return Ok(());
    }
    Err(format!(
        "package check failed:\n{}",
        report
            .errors
            .iter()
            .map(|diagnostic| format!("- {}: {}", diagnostic.field, diagnostic.message))
            .collect::<Vec<_>>()
            .join("\n")
    )
    .into())
}

pub(crate) fn render_package_api_docs(report: &PackageCheckReport) -> String {
    let title = report.name.as_deref().unwrap_or("package");
    let mut out = format!("# API Reference: {title}\n\nGenerated by `harn package docs`.\n");
    if let Some(version) = report.version.as_deref() {
        out.push_str(&format!("\nVersion: `{version}`\n"));
    }
    for export in &report.exports {
        out.push_str(&format!(
            "\n## Export `{}`\n\n`{}`\n",
            export.name, export.path
        ));
        for symbol in &export.symbols {
            out.push_str(&format!("\n### {} `{}`\n\n", symbol.kind, symbol.name));
            if let Some(docs) = symbol.docs.as_deref() {
                out.push_str(docs);
                out.push_str("\n\n");
            }
            out.push_str("```harn\n");
            out.push_str(&symbol.signature);
            out.push_str("\n```\n");
        }
    }
    if !report.tools.is_empty() {
        out.push_str("\n## Tool Exports\n");
        for tool in &report.tools {
            out.push_str(&format!(
                "\n### `{}`\n\n- module: `{}`\n- symbol: `{}`\n",
                tool.name, tool.module, tool.symbol
            ));
            if !tool.permissions.is_empty() {
                out.push_str(&format!(
                    "- permissions: `{}`\n",
                    tool.permissions.join("`, `")
                ));
            }
            if !tool.host_requirements.is_empty() {
                out.push_str(&format!(
                    "- host requirements: `{}`\n",
                    tool.host_requirements.join("`, `")
                ));
            }
        }
    }
    if !report.skills.is_empty() {
        out.push_str("\n## Skill Exports\n");
        for skill in &report.skills {
            out.push_str(&format!("\n### `{}`\n\n`{}`\n", skill.name, skill.path));
        }
    }
    out
}

pub(crate) fn normalize_newlines(input: &str) -> String {
    input.replace("\r\n", "\n")
}