// 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, " "))
}