use net_sdk::tool::ToolDescriptor;
use super::schema::{self, Additional, ParsedSchema, Primitive, Schema};
use super::{module_basename, pascal_case, GenMeta, GeneratedFile};
use crate::error::CliError;
pub(super) fn generate(
descriptors: &[ToolDescriptor],
meta: &GenMeta,
skipped: &mut Vec<String>,
) -> Result<Vec<GeneratedFile>, CliError> {
let mut files = Vec::new();
let mut modules: Vec<String> = Vec::new();
for d in descriptors {
match render_module(d, meta) {
Ok(contents) => {
let base = module_basename(&d.tool_id);
files.push(GeneratedFile {
rel_path: format!("tools/{base}.ts"),
contents,
});
modules.push(base);
}
Err(reason) => {
eprintln!("warning: tool `{}` skipped — {reason}", d.tool_id);
skipped.push(d.tool_id.clone());
}
}
}
files.push(GeneratedFile {
rel_path: "index.ts".to_string(),
contents: render_index(&modules),
});
files.push(GeneratedFile {
rel_path: "meta.json".to_string(),
contents: render_meta_json(meta, &modules),
});
Ok(files)
}
fn render_module(d: &ToolDescriptor, meta: &GenMeta) -> Result<String, String> {
let prefix = pascal_case(&d.tool_id);
let input = schema::parse(d.input_schema.as_deref().unwrap_or("{}"))
.map_err(|e| format!("input schema: {e}"))?;
let output = match &d.output_schema {
Some(s) => Some(schema::parse(s).map_err(|e| format!("output schema: {e}"))?),
None => None,
};
let ctx = Ctx { prefix: &prefix };
let mut out = String::new();
out.push_str(&header(d, meta));
out.push('\n');
emit_defs(&mut out, &input, &ctx);
if let Some(o) = &output {
emit_defs(&mut out, o, &ctx);
}
let req_name = format!("{prefix}Request");
out.push_str(&render_named(
&req_name,
&input.root,
input.doc.as_deref().or(Some("Request body")),
d,
true,
&ctx,
));
out.push('\n');
let resp_name = format!("{prefix}Response");
match &output {
Some(o) => {
out.push_str(&render_named(
&resp_name,
&o.root,
o.doc.as_deref().or(Some("Response body")),
d,
false,
&ctx,
));
}
None => {
out.push_str(&format!(
"/** Response body for `{}`. The descriptor carried no output schema. */\nexport type {resp_name} = unknown;\n",
d.tool_id
));
}
}
out.push('\n');
out.push_str(&render_meta_const(&prefix, d));
out.push('\n');
out.push_str(&render_call_helper(&prefix));
Ok(out)
}
struct Ctx<'a> {
prefix: &'a str,
}
fn header(d: &ToolDescriptor, meta: &GenMeta) -> String {
let version = if d.version.is_empty() {
String::new()
} else {
format!(" v{}", d.version)
};
format!(
"// Auto-generated by `net-mesh typegen`. Do not edit by hand.\n\
// Source: tool `{}`{}\n\
// Generated from {} @ {}\n",
d.tool_id, version, meta.source_label, meta.captured_at,
)
}
fn emit_defs(out: &mut String, parsed: &ParsedSchema, ctx: &Ctx) {
for (name, schema) in &parsed.defs {
let ty_name = format!("{}{}", ctx.prefix, pascal_case(name));
out.push_str(&render_type_decl(&ty_name, schema, None, ctx));
out.push('\n');
}
}
fn render_named(
name: &str,
schema: &Schema,
doc: Option<&str>,
d: &ToolDescriptor,
is_request: bool,
ctx: &Ctx,
) -> String {
let kind = if is_request { "Request" } else { "Response" };
let doc = doc
.map(str::to_string)
.unwrap_or_else(|| format!("{kind} body for `{}`.", d.tool_id));
render_type_decl(name, schema, Some(&doc), ctx)
}
fn render_type_decl(name: &str, schema: &Schema, doc: Option<&str>, ctx: &Ctx) -> String {
let doc_line = match doc {
Some(d) => format!("/** {} */\n", d.replace("*/", "* /")),
None => String::new(),
};
match schema {
Schema::Object(obj) if !obj.properties.is_empty() => {
let mut s = format!("{doc_line}export interface {name} {{\n");
for (field, prop) in &obj.properties {
let optional = if obj.required.contains(field) {
""
} else {
"?"
};
s.push_str(&format!(
" {}{}: {};\n",
ts_key(field),
optional,
render_type(prop, ctx)
));
}
match &obj.additional {
Additional::Typed(t) => {
s.push_str(&format!(
" [key: string]: {};\n",
typed_index_value(obj, t, ctx)
));
}
Additional::Allowed if !obj.properties.is_empty() => {
s.push_str(" [key: string]: unknown;\n");
}
_ => {}
}
s.push_str("}\n");
s
}
other => format!(
"{doc_line}export type {name} = {};\n",
render_type(other, ctx)
),
}
}
fn render_type(schema: &Schema, ctx: &Ctx) -> String {
match schema {
Schema::Primitive(p) => match p {
Primitive::String => "string".into(),
Primitive::Integer | Primitive::Number => "number".into(),
Primitive::Boolean => "boolean".into(),
Primitive::Null => "null".into(),
},
Schema::Array(inner) => {
let t = render_type(inner, ctx);
if matches!(**inner, Schema::Union(_) | Schema::Intersection(_)) {
format!("({t})[]")
} else {
format!("{t}[]")
}
}
Schema::Tuple(items) => {
let parts: Vec<String> = items.iter().map(|s| render_type(s, ctx)).collect();
format!("[{}]", parts.join(", "))
}
Schema::Object(obj) => render_inline_object(obj, ctx),
Schema::Enum(values) => {
if values.is_empty() {
"never".into()
} else {
values.iter().map(literal).collect::<Vec<_>>().join(" | ")
}
}
Schema::Const(v) => literal(v),
Schema::Union(branches) => union(branches, ctx, " | "),
Schema::Intersection(parts) => union(parts, ctx, " & "),
Schema::Ref(name) => format!("{}{}", ctx.prefix, pascal_case(name)),
Schema::Unknown => "unknown".into(),
}
}
fn render_inline_object(obj: &schema::ObjectSchema, ctx: &Ctx) -> String {
if obj.properties.is_empty() {
return match &obj.additional {
Additional::Typed(t) => format!("Record<string, {}>", render_type(t, ctx)),
Additional::Denied => "Record<string, never>".into(),
Additional::Allowed | Additional::Unspecified => "Record<string, unknown>".into(),
};
}
let mut fields: Vec<String> = obj
.properties
.iter()
.map(|(field, prop)| {
let optional = if obj.required.contains(field) {
""
} else {
"?"
};
format!("{}{}: {}", ts_key(field), optional, render_type(prop, ctx))
})
.collect();
match &obj.additional {
Additional::Typed(t) => {
fields.push(format!("[key: string]: {}", typed_index_value(obj, t, ctx)))
}
Additional::Allowed => fields.push("[key: string]: unknown".into()),
Additional::Denied | Additional::Unspecified => {}
}
format!("{{ {} }}", fields.join("; "))
}
fn typed_index_value(obj: &schema::ObjectSchema, extra: &Schema, ctx: &Ctx) -> String {
let mut parts = vec![render_type(extra, ctx)];
let mut push = |t: String| {
if !parts.contains(&t) {
parts.push(t);
}
};
for (field, prop) in &obj.properties {
push(render_type(prop, ctx));
if !obj.required.contains(field) {
push("undefined".into());
}
}
parts.join(" | ")
}
fn union(branches: &[Schema], ctx: &Ctx, sep: &str) -> String {
if branches.is_empty() {
return "unknown".into();
}
branches
.iter()
.map(|s| {
let t = render_type(s, ctx);
if matches!(s, Schema::Object(o) if !o.properties.is_empty()) {
format!("({t})")
} else {
t
}
})
.collect::<Vec<_>>()
.join(sep)
}
fn literal(v: &serde_json::Value) -> String {
match v {
serde_json::Value::String(s) => {
serde_json::to_string(s).unwrap_or_else(|_| "string".into())
}
serde_json::Value::Null => "null".into(),
other => other.to_string(),
}
}
fn ts_key(name: &str) -> String {
let mut chars = name.chars();
let valid = matches!(chars.next(), Some(c) if c.is_ascii_alphabetic() || c == '_' || c == '$')
&& chars.all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '$')
&& !name.is_empty();
if valid {
name.to_string()
} else {
serde_json::to_string(name).unwrap_or_else(|_| format!("\"{name}\""))
}
}
fn render_meta_const(prefix: &str, d: &ToolDescriptor) -> String {
let description = match &d.description {
Some(s) => serde_json::to_string(s).unwrap_or_else(|_| "\"\"".into()),
None => "null".into(),
};
let tags = serde_json::to_string(&d.tags).unwrap_or_else(|_| "[]".into());
format!(
"/** Descriptor metadata captured at generation time. */\n\
export const {prefix}Meta = {{\n\
\x20 toolId: {tool_id},\n\
\x20 version: {version},\n\
\x20 description: {description},\n\
\x20 streaming: {streaming},\n\
\x20 stateless: {stateless},\n\
\x20 estimatedTimeMs: {estimated},\n\
\x20 tags: {tags},\n\
}} as const;\n",
tool_id = serde_json::to_string(&d.tool_id).unwrap_or_else(|_| "\"\"".into()),
version = serde_json::to_string(&d.version).unwrap_or_else(|_| "\"\"".into()),
streaming = d.streaming,
stateless = d.stateless,
estimated = d.estimated_time_ms,
)
}
fn render_call_helper(prefix: &str) -> String {
format!(
"// Runtime call helper. The mesh client is kept structural so the\n\
// generated code doesn't pin to a specific SDK version.\n\
export async function call{prefix}(\n\
\x20 mesh: {{ call: (tool: string, input: unknown) => Promise<unknown> }},\n\
\x20 input: {prefix}Request,\n\
): Promise<{prefix}Response> {{\n\
\x20 return (await mesh.call({prefix}Meta.toolId, input)) as {prefix}Response;\n\
}}\n"
)
}
fn render_index(modules: &[String]) -> String {
let mut s = String::from("// Auto-generated by `net-mesh typegen`. Do not edit by hand.\n");
for m in modules {
s.push_str(&format!("export * from \"./tools/{m}\";\n"));
}
s
}
fn render_meta_json(meta: &GenMeta, modules: &[String]) -> String {
let value = serde_json::json!({
"format_version": meta.format_version,
"source": meta.source_label,
"captured_at": meta.captured_at,
"language": "ts",
"modules": modules,
});
serde_json::to_string_pretty(&value).unwrap_or_else(|_| "{}".into())
}
#[cfg(test)]
mod tests {
use super::*;
fn ty(json: &str) -> String {
let root = schema::parse(json).expect("parse").root;
render_type(&root, &Ctx { prefix: "X" })
}
fn descriptor(input: &str, output: Option<&str>) -> ToolDescriptor {
ToolDescriptor {
tool_id: "acme/web_search".into(),
name: "Web Search".into(),
version: "1.2.0".into(),
description: Some("Search the web for query terms".into()),
input_schema: Some(input.into()),
output_schema: output.map(str::to_string),
requires: vec![],
estimated_time_ms: 800,
stateless: true,
streaming: false,
tags: vec!["search".into(), "io".into()],
node_count: 0,
}
}
fn meta() -> GenMeta {
GenMeta {
source_label: "snapshot tools.snapshot".into(),
captured_at: "2026-06-04T10:00:00Z".into(),
format_version: 1,
}
}
#[test]
fn type_translation_rules() {
assert_eq!(ty(r#"{"type":"string"}"#), "string");
assert_eq!(ty(r#"{"type":"integer"}"#), "number");
assert_eq!(ty(r#"{"type":"boolean"}"#), "boolean");
assert_eq!(
ty(r#"{"type":"array","items":{"type":"string"}}"#),
"string[]"
);
assert_eq!(ty(r#"{"enum":["a","b","c"]}"#), r#""a" | "b" | "c""#);
assert_eq!(
ty(r#"{"oneOf":[{"type":"string"},{"type":"integer"}]}"#),
"string | number"
);
assert_eq!(
ty(r##"{"allOf":[{"$ref":"#/$defs/A"},{"$ref":"#/$defs/B"}]}"##),
"XA & XB"
);
assert_eq!(ty(r#"{"type":"string","nullable":true}"#), "string | null");
assert_eq!(ty("{}"), "unknown");
assert_eq!(
ty(r#"{"type":"array","items":{"oneOf":[{"type":"string"},{"type":"integer"}]}}"#),
"(string | number)[]"
);
}
#[test]
fn additional_properties_index_signature() {
assert_eq!(
ty(r#"{"type":"object","additionalProperties":{"type":"number"}}"#),
"Record<string, number>"
);
assert_eq!(ty(r#"{"type":"object"}"#), "Record<string, unknown>");
}
#[test]
fn typed_index_signature_widens_to_cover_named_props() {
assert_eq!(
ty(
r#"{"type":"object","properties":{"name":{"type":"string"}},"required":[],"additionalProperties":{"type":"number"}}"#
),
"{ name?: string; [key: string]: number | string | undefined }"
);
}
#[test]
fn empty_closed_object_renders_record_never_not_empty_interface() {
let root = schema::parse(r#"{"type":"object","additionalProperties":false}"#)
.expect("parse")
.root;
let decl = render_type_decl("Foo", &root, None, &Ctx { prefix: "X" });
assert_eq!(decl, "export type Foo = Record<string, never>;\n");
}
#[test]
fn ts_key_quotes_invalid_identifiers() {
assert_eq!(ts_key("query"), "query");
assert_eq!(ts_key("max_results"), "max_results");
assert_eq!(ts_key("weird-name"), "\"weird-name\"");
assert_eq!(ts_key("3d"), "\"3d\"");
}
#[test]
fn module_emits_request_response_meta_and_helper() {
let d = descriptor(
r#"{"type":"object","properties":{"query":{"type":"string"},"max_results":{"type":"integer"}},"required":["query"]}"#,
Some(
r##"{"type":"object","properties":{"results":{"type":"array","items":{"$ref":"#/$defs/Result"}}},"$defs":{"Result":{"type":"object","properties":{"url":{"type":"string"},"title":{"type":"string"}},"required":["url","title"]}}}"##,
),
);
let m = render_module(&d, &meta()).expect("render module");
assert!(m.contains("export interface AcmeWebSearchRequest {"), "{m}");
assert!(m.contains("query: string;"), "{m}");
assert!(m.contains("max_results?: number;"), "{m}");
assert!(m.contains("export interface AcmeWebSearchResult {"), "{m}");
assert!(m.contains("results?: AcmeWebSearchResult[];"), "{m}");
assert!(m.contains("export const AcmeWebSearchMeta = {"), "{m}");
assert!(m.contains(r#"toolId: "acme/web_search","#), "{m}");
assert!(m.contains("streaming: false,"), "{m}");
assert!(
m.contains("export async function callAcmeWebSearch("),
"{m}"
);
assert!(
m.contains("// Auto-generated by `net-mesh typegen`."),
"{m}"
);
}
#[test]
fn missing_output_schema_yields_unknown_response() {
let d = descriptor(
r#"{"type":"object","properties":{"q":{"type":"string"}}}"#,
None,
);
let m = render_module(&d, &meta()).expect("render");
assert!(
m.contains("export type AcmeWebSearchResponse = unknown;"),
"{m}"
);
}
#[test]
fn unsupported_schema_is_skipped_not_fatal() {
let bad = descriptor(r#"{"not":{"type":"string"}}"#, None);
let mut skipped = Vec::new();
let files = generate(&[bad], &meta(), &mut skipped).expect("generate");
assert_eq!(skipped, vec!["acme/web_search".to_string()]);
assert!(files.iter().any(|f| f.rel_path == "index.ts"));
assert!(!files.iter().any(|f| f.rel_path.starts_with("tools/")));
}
}