harn-cli 0.10.144

CLI for the Harn programming language — run, test, REPL, format, and lint
//! Shared field projections for protocol records and session-update payloads.

use std::borrow::Cow;

#[derive(Clone, Debug, PartialEq, Eq)]
pub(super) enum FieldKind {
    NonEmptyString,
    String,
    Integer(Integer),
    Bool,
    LiteralBool(bool),
    Json,
    Named(String),
    List(Box<Self>),
    /// An optional list whose Rust projection defaults to an empty vector.
    DefaultList(Box<Self>),
    Nullable(Box<Self>),
    JsonObject,
    Literal {
        value: String,
        wire_type: Option<String>,
    },
}

#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(super) enum Integer {
    /// Existing plan ranges use Rust usize and Go's machine-sized int.
    HostIndex,
    /// Harn's signed integer projects to Swift Int64.
    Harn,
    /// Existing permission timestamps retain Rust u64 and Swift Int64.
    UnsignedHarn,
    U64,
    U32,
    Usize,
    I64,
}

#[derive(Clone, Copy)]
pub(super) enum Target {
    Rust,
    Swift,
    Typescript,
    Python,
    Go,
}

impl FieldKind {
    pub(super) fn type_name(&self, target: Target) -> String {
        use Target::*;
        match self {
            Self::Integer(integer) => match target {
                Rust => match integer {
                    Integer::U64 | Integer::UnsignedHarn => "u64",
                    Integer::U32 => "u32",
                    Integer::Usize | Integer::HostIndex => "usize",
                    Integer::I64 | Integer::Harn => "i64",
                },
                Go => match integer {
                    Integer::U32 => "uint32",
                    Integer::I64 | Integer::Harn => "int64",
                    Integer::HostIndex => "int",
                    _ => "uint64",
                },
                Swift if matches!(integer, Integer::Harn | Integer::UnsignedHarn) => "Int64",
                Swift => "Int",
                Typescript => "number",
                Python => "int",
            }
            .into(),
            Self::Nullable(inner) => match target {
                Typescript => format!("{} | null", inner.type_name(target)),
                Go if matches!(inner.as_ref(), Self::Json) => "JSONValue".into(),
                _ => inner.optional_type(target, false),
            },
            Self::JsonObject => match target {
                Rust => "std::collections::BTreeMap<String, Value>",
                Swift => "[String: HarnACPValue]",
                Typescript => "Record<string, ACPValue>",
                Python => "Dict[str, JsonValue]",
                Go => "JSONObject",
            }
            .into(),
            Self::Named(name) if name == "HarnACPToolKind" && matches!(target, Typescript) => {
                "ACPToolKind".into()
            }
            Self::Named(name) => name.clone(),
            Self::LiteralBool(value) => match target {
                Typescript => value.to_string(),
                _ => Self::Bool.type_name(target),
            },
            Self::Literal { value, wire_type } => match target {
                Typescript => super::support::json_string_literal(value),
                _ => wire_type
                    .clone()
                    .unwrap_or_else(|| Self::String.type_name(target)),
            },
            Self::List(inner) | Self::DefaultList(inner) => {
                let item = inner.type_name(target);
                match target {
                    Rust => format!("Vec<{item}>"),
                    Swift => format!("[{item}]"),
                    Typescript if matches!(inner.as_ref(), Self::Nullable(_)) => {
                        format!("({item})[]")
                    }
                    Typescript => format!("{item}[]"),
                    Python => format!("List[{item}]"),
                    Go => format!("[]{item}"),
                }
            }
            kind => match (kind, target) {
                (Self::NonEmptyString | Self::String, Rust | Swift) => "String",
                (Self::NonEmptyString | Self::String, Python) => "str",
                (Self::NonEmptyString | Self::String, Typescript | Go) => "string",
                (Self::Bool, Rust | Go) => "bool",
                (Self::Bool, Swift) => "Bool",
                (Self::Bool, Typescript) => "boolean",
                (Self::Bool, Python) => "bool",
                (Self::Json, Rust) => "Value",
                (Self::Json, Swift) => "HarnACPValue",
                (Self::Json, Typescript) => "ACPValue",
                (Self::Json, Python) => "JsonValue",
                (Self::Json, Go) => "json.RawMessage",
                _ => unreachable!("composite field handled above"),
            }
            .into(),
        }
    }

    pub(super) fn optional_type(&self, target: Target, required: bool) -> String {
        let inner = self.type_name(target);
        if required || (matches!(target, Target::Rust) && matches!(self, Self::DefaultList(_))) {
            return inner;
        }
        match target {
            Target::Rust => format!("Option<{inner}>"),
            Target::Swift => format!("{inner}?"),
            Target::Python => format!("Optional[{inner}]"),
            Target::Go if !matches!(self, Self::List(_) | Self::DefaultList(_) | Self::Json) => {
                format!("*{inner}")
            }
            _ => inner,
        }
    }
}

#[derive(Clone, Debug, PartialEq, Eq)]
pub(super) struct Field {
    pub wire_name: Cow<'static, str>,
    pub rust_name: Cow<'static, str>,
    pub kind: FieldKind,
    pub required: bool,
}

#[derive(Clone, Debug, PartialEq, Eq)]
pub(super) struct Record {
    pub name: String,
    pub fields: Vec<Field>,
}

impl Record {
    pub(super) fn append(&self, out: &mut String, target: Target) {
        self.append_record(out, target, false, false, false);
    }

    pub(super) fn append_closed(&self, out: &mut String, target: Target) {
        self.append_record(out, target, true, true, false);
    }

    pub(super) fn append_mutable(
        &self,
        out: &mut String,
        target: Target,
        preserve_wire_presence: bool,
    ) {
        self.append_record(out, target, false, true, preserve_wire_presence);
    }

    fn append_record(
        &self,
        out: &mut String,
        target: Target,
        closed: bool,
        mutable: bool,
        preserve_wire_presence: bool,
    ) {
        let name = &self.name;
        match target {
            Target::Rust if closed => out.push_str(&format!("#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]\n#[serde(rename_all = \"camelCase\", deny_unknown_fields)]\npub struct {name} {{\n")),
            Target::Rust => out.push_str(&format!("#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]\npub struct {name} {{\n")),
            Target::Swift => out.push_str(&format!("public struct {name}: Codable, Sendable, Equatable {{\n")),
            Target::Typescript => out.push_str(&format!("export interface {name} {{\n")),
            Target::Python => out.push_str(&format!("@dataclass\nclass {name}({}):\n", if closed { "_HarnStrictRecapDataclass" } else { "_HarnDataclass" })),
            Target::Go => out.push_str(&format!("type {name} struct {{\n")),
        }
        for field in &self.fields {
            let optional = if field.required { "" } else { "?" };
            let kind = field.kind.optional_type(target, field.required);
            match target {
                Target::Rust => {
                    if closed && matches!(field.kind, FieldKind::JsonObject) {
                        out.push_str("    #[serde(default)]\n");
                    }
                    if !field.required {
                        let predicate = if matches!(field.kind, FieldKind::DefaultList(_)) {
                            "Vec::is_empty"
                        } else {
                            "Option::is_none"
                        };
                        out.push_str(&format!(
                            "    #[serde(default, skip_serializing_if = {predicate:?})]\n"
                        ));
                        if preserve_wire_presence && matches!(field.kind, FieldKind::Json) {
                            out.push_str("    #[serde(deserialize_with = \"deserialize_present_session_update_value\")]\n");
                        }
                    }
                    if !closed && field.rust_name != field.wire_name {
                        out.push_str(&format!("    #[serde(rename = {:?})]\n", field.wire_name));
                    }
                    out.push_str(&format!("    pub {}: {kind},\n", field.rust_name));
                }
                Target::Swift => out.push_str(&format!(
                    "    public {} {}: {kind}\n",
                    if mutable { "var" } else { "let" },
                    camel_ident(&field.wire_name)
                )),
                Target::Typescript => {
                    out.push_str(&format!("  {}{optional}: {kind}\n", field.wire_name));
                }
                Target::Python => out.push_str(&format!(
                    "    {}: {kind}{}\n",
                    field.wire_name,
                    if !closed && !field.required {
                        " = None"
                    } else {
                        ""
                    }
                )),
                Target::Go => out.push_str(&format!(
                    "    {} {kind} `json:\"{}{}\"`\n",
                    go_ident(&field.wire_name),
                    field.wire_name,
                    if field.required { "" } else { ",omitempty" },
                )),
            }
        }
        let explicit_swift_nulls = matches!(target, Target::Swift)
            && preserve_wire_presence
            && self
                .fields
                .iter()
                .any(|field| field.required && matches!(field.kind, FieldKind::Nullable(_)));
        let explicit_swift_presence = matches!(target, Target::Swift)
            && preserve_wire_presence
            && self
                .fields
                .iter()
                .any(|field| !field.required && matches!(field.kind, FieldKind::Json));
        if matches!(target, Target::Swift)
            && (explicit_swift_nulls
                || explicit_swift_presence
                || self
                    .fields
                    .iter()
                    .any(|field| camel_ident(&field.wire_name) != field.wire_name))
        {
            out.push_str("\n    enum CodingKeys: String, CodingKey {\n");
            for field in &self.fields {
                let name = camel_ident(&field.wire_name);
                out.push_str(&format!("        case {name}"));
                if name != field.wire_name {
                    out.push_str(&format!(" = {:?}", field.wire_name));
                }
                out.push('\n');
            }
            out.push_str("    }\n");
        }
        // A required-nullable field needs the custom decoder just as much as a
        // present-versus-absent one does, and for a reason the synthesized
        // decoder cannot express: the Swift property is `T?` either way, so
        // Codable synthesis reaches for `decodeIfPresent` and an omitted key
        // reads as `nil` rather than as the contract violation it is.
        //
        // Gating this on `explicit_swift_presence` alone was silently
        // conditional on an unrelated fact. A struct keeps its enforcement
        // only while some OTHER field happens to be a non-required JSON value,
        // and loses it the moment those fields become required — which is what
        // happened to the transcript-compacted meta struct, one of eleven,
        // when its optional JSON fields were tightened. The encoder below
        // stayed, so the artifact still wrote every key while no longer
        // refusing a frame that omitted one.
        if explicit_swift_nulls || explicit_swift_presence {
            out.push_str("\n    public init(from decoder: Decoder) throws {\n        let values = try decoder.container(keyedBy: CodingKeys.self)\n");
            for field in &self.fields {
                let name = camel_ident(&field.wire_name);
                let kind = field.kind.type_name(target);
                if !field.required && matches!(field.kind, FieldKind::Json) {
                    out.push_str(&format!("        {name} = values.contains(.{name}) ? try values.decode({kind}.self, forKey: .{name}) : nil\n"));
                } else {
                    let decode = if field.required {
                        "decode"
                    } else {
                        "decodeIfPresent"
                    };
                    out.push_str(&format!(
                        "        {name} = try values.{decode}({kind}.self, forKey: .{name})\n"
                    ));
                }
            }
            out.push_str("    }\n");
        }
        if explicit_swift_nulls {
            out.push_str("\n    public func encode(to encoder: Encoder) throws {\n        var values = encoder.container(keyedBy: CodingKeys.self)\n");
            for field in &self.fields {
                let name = camel_ident(&field.wire_name);
                let encode = if field.required {
                    "encode"
                } else {
                    "encodeIfPresent"
                };
                out.push_str(&format!(
                    "        try values.{encode}({name}, forKey: .{name})\n"
                ));
            }
            out.push_str("    }\n");
        }
        out.push_str(if matches!(target, Target::Python) {
            "\n"
        } else {
            "}\n\n"
        });
    }
}

fn camel_ident(value: &str) -> String {
    let mut parts = value.trim_start_matches('_').split('_');
    let mut out = parts.next().unwrap_or_default().to_owned();
    for part in parts {
        let mut chars = part.chars();
        if let Some(first) = chars.next() {
            out.extend(first.to_uppercase());
        }
        out.extend(chars);
    }
    out
}

pub(super) fn snake_ident(value: &str) -> String {
    let mut out = String::new();
    for (index, ch) in value.chars().enumerate() {
        if ch.is_uppercase() && index > 0 {
            out.push('_');
        }
        out.extend(ch.to_lowercase());
    }
    out
}

fn go_ident(value: &str) -> String {
    snake_ident(value)
        .split('_')
        .map(|word| {
            if word == "id" {
                return "ID".into();
            }
            if word == "ids" {
                return "IDs".into();
            }
            let mut chars = word.chars();
            chars
                .next()
                .map(|ch| ch.to_uppercase().to_string())
                .unwrap_or_default()
                + chars.as_str()
        })
        .collect()
}

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

    #[test]
    fn nullable_list_elements_keep_typescript_union_precedence() {
        let kind = FieldKind::List(Box::new(FieldKind::Nullable(Box::new(FieldKind::String))));
        assert_eq!(kind.type_name(Target::Typescript), "(string | null)[]");
    }
}