harn-stdlib 0.9.6

Embedded Harn standard library source catalog
Documentation
// std/cli/render — output helpers for CLI subcommand `.harn` scripts
// dispatched via the harn-cli wedge (harn#2293 epic, harn#2296 G3).
//
// Most port-facing rendering primitives already live elsewhere in the
// stdlib — this module is intentionally a thin layer that fills the
// gaps:
//
//   * Color & tty detection: import from `std/ansi` (`ansi_color`,
//     `ansi_bold`, `ansi_strip`, `ansi_enabled`) and `std/io` (`is_tty`).
//     Both honor `NO_COLOR` and `HARN_COLOR` per the existing
//     `ansi_enabled` policy.
//   * Tables: `std/table` already provides `render_table`,
//     `render_markdown_table`, and `render_kv_table` with column
//     auto-width, alignment, and per-cell truncation.
//   * Diffs: `std/diff` covers Myers-style diff rendering.
//
// What this module adds:
//
//   * `envelope` / `write_envelope` — stable-shape JSON envelope wrappers
//     for `--json` mode, with pinned top-level key ordering so snapshot
//     tests stay stable across runs.
//   * `mode()` — small helper for "am I in JSON mode?" so port scripts
//     don't have to read `HARN_OUTPUT_JSON` directly.
//   * Small coercion and shell-argv helpers for renderer scripts consuming
//     JSON payloads collected by Rust shims.
import { is_tty } from "std/io"

/**
 * Returns "json" when the host (clap) saw `--json`, or the user's
 * `HARN_OUTPUT_JSON=1` env is set; otherwise "human". The harn-cli
 * dispatch wedge sets the env var when `--json` is parsed at the host
 * level (see harn#2294 G1).
 *
 * @effects: [env.read]
 * @errors: []
 */
pub fn mode(harness: Harness) -> string {
  if harness.env.get_or("HARN_OUTPUT_JSON", "0") == "1" {
    return "json"
  }
  return "human"
}

/**
 * True when the script should emit a JSON envelope instead of human
 * output. Sugar over `mode() == "json"`.
 *
 * @effects: [env.read]
 * @errors: []
 */
pub fn json_mode(harness: Harness) -> bool {
  return mode(harness) == "json"
}

/**
 * Shape of an `envelope` result. `schema_version` plus `api_stability`
 * always come first in the serialized form so consumers can switch on
 * them without parsing prose.
 */
type Envelope = {schema_version: int, api_stability: string, payload: any, warnings?: list<string>}

/**
 * Wrap `payload` in a stable-shape JSON envelope. Harn's
 * `json_stringify` serializes dict keys alphabetically — so the
 * envelope's serialized form is always `apiStability` < `payload` <
 * `schemaVersion` < `warnings`, deterministically, regardless of how
 * the script builds the dict. Snapshot tests can rely on that order.
 *
 * `api_stability` should be one of: "stable", "experimental", "internal".
 *
 * @effects: []
 * @errors: []
 * @example: envelope({schema_version: 1, api_stability: "stable", payload: {ok: true}})
 */
pub fn envelope(spec: Envelope) -> dict {
  // Insertion order doesn't matter — json_stringify sorts keys
  // alphabetically — but build the dict in a readable order anyway.
  var out = {schemaVersion: spec.schema_version, apiStability: spec.api_stability}
  if spec.warnings != nil && len(spec.warnings) > 0 {
    out = out + {warnings: spec.warnings}
  }
  out = out + {payload: spec.payload}
  return out
}

/** Shape of the canonical harn-cli JSON envelope. */
type CliJsonEnvelope = {schema_version: int, ok: bool, data?: any, error?: any, warnings?: list}

/**
 * Wrap `data` in the canonical harn-cli JSON envelope:
 * `{schemaVersion, ok, data, error, warnings}`.
 *
 * Use this for subcommands documented by `harn --json-schemas`. The older
 * `envelope()` helper is kept for command-specific `apiStability` payloads.
 *
 * @effects: []
 * @errors: []
 */
pub fn cli_json_envelope(spec: CliJsonEnvelope) -> dict {
  let data = if spec.data == nil {
    nil
  } else {
    spec.data
  }
  let error = if spec.error == nil {
    nil
  } else {
    spec.error
  }
  let warnings = if spec.warnings == nil {
    []
  } else {
    spec.warnings
  }
  return {schemaVersion: spec.schema_version, ok: spec.ok, data: data, error: error, warnings: warnings}
}

/**
 * Serialize an envelope and write it to stdout. Uses pretty-printed
 * JSON when stdout is a tty (so humans can read it without piping
 * through `jq`), compact JSON otherwise.
 *
 * @effects: [stdio.write]
 * @errors: []
 * @example: write_envelope(envelope({schema_version: 1, api_stability: "stable", payload: result}))
 */
pub fn write_envelope(env: dict) {
  let text = if is_tty(1) {
    json_stringify_pretty(env)
  } else {
    json_stringify(env)
  }
  __io_println(text)
}

/**
 * Return `value` when it is a string, otherwise `fallback`.
 *
 * @effects: []
 * @errors: []
 */
pub fn safe_string(value, fallback: string) -> string {
  if type_of(value) == "string" {
    return value
  }
  return fallback
}

/**
 * Return `value` when it is a non-empty string, otherwise `fallback`.
 *
 * @effects: []
 * @errors: []
 */
pub fn nonempty_string(value, fallback: string) -> string {
  let text = safe_string(value, "")
  if text == "" {
    return fallback
  }
  return text
}

/**
 * Return `value` when it is a dict, otherwise an empty dict.
 *
 * @effects: []
 * @errors: []
 */
pub fn safe_dict(value) -> dict {
  if type_of(value) == "dict" {
    return value
  }
  return {}
}

/**
 * Return `value` when it is a list, otherwise an empty list.
 *
 * @effects: []
 * @errors: []
 */
pub fn safe_list(value) -> list {
  if type_of(value) == "list" {
    return value
  }
  return []
}

/**
 * Return `value` when it is a bool, otherwise `fallback`.
 *
 * @effects: []
 * @errors: []
 */
pub fn safe_bool(value, fallback: bool) -> bool {
  if type_of(value) == "bool" {
    return value
  }
  return fallback
}

/**
 * Render an int as a string, otherwise return `fallback`.
 *
 * @effects: []
 * @errors: []
 */
pub fn safe_int_string(value, fallback: string) -> string {
  if type_of(value) == "int" {
    return to_string(value)
  }
  return fallback
}

/**
 * Render an int or float as a string, otherwise return `fallback`.
 *
 * @effects: []
 * @errors: []
 */
pub fn safe_number_string(value, fallback: string) -> string {
  let kind = type_of(value)
  if kind == "int" || kind == "float" {
    return to_string(value)
  }
  return fallback
}

/**
 * Quote one shell argv item for human-display commands.
 *
 * @effects: []
 * @errors: []
 */
pub fn shell_quote(value: string) -> string {
  if value == "" {
    return "''"
  }
  if regex_match("^[A-Za-z0-9_./:=+-]+$", value) != nil {
    return value
  }
  return "'" + replace(value, "'", "'\"'\"'") + "'"
}

/**
 * Render an argv list as a shell-display command.
 *
 * @effects: []
 * @errors: []
 */
pub fn join_shell_argv(items: list, sep: string) -> string {
  var out = ""
  var first = true
  for item in items {
    let text = shell_quote(safe_string(item, ""))
    if text == "" {
      continue
    }
    if !first {
      out = out + sep
    }
    out = out + text
    first = false
  }
  return out
}

/**
 * Print a labelled bullet list, skipping non-string/empty entries.
 *
 * @effects: [stdio.write]
 * @errors: []
 */
pub fn print_list(harness: Harness, label: string, items: list) {
  if items.count == 0 {
    return
  }
  harness.stdio.println("  " + label + ":")
  for item in items {
    let text = safe_string(item, "")
    if text != "" {
      harness.stdio.println("    - " + text)
    }
  }
}

/**
 * Print a labelled shell-display command from argv.
 *
 * @effects: [stdio.write]
 * @errors: []
 */
pub fn render_command(harness: Harness, label: string, argv: list) {
  if argv.count == 0 {
    return
  }
  harness.stdio.println("  " + label + ":")
  harness.stdio.println("    " + join_shell_argv(argv, " "))
}