#[derive(Debug, Clone, PartialEq, Eq)]
#[non_exhaustive] pub struct BatchItem {
pub name: String,
pub source: Option<String>,
}
pub fn parse_items(args: &serde_json::Value) -> Result<Vec<BatchItem>, String> {
let names = args.get("names").and_then(|v| v.as_array());
let items = args.get("items").and_then(|v| v.as_array());
if names.map(|a| !a.is_empty()).unwrap_or(false)
&& items.map(|a| !a.is_empty()).unwrap_or(false)
{
return Err("pass `names` OR `items`, not both".into());
}
if let Some(arr) = items.filter(|a| !a.is_empty()) {
let mut out = Vec::with_capacity(arr.len());
for (i, v) in arr.iter().enumerate() {
let Some(obj) = v.as_object() else {
return Err(format!("items[{i}] must be an object {{ name, source? }}"));
};
let name = obj
.get("name")
.and_then(|v| v.as_str())
.unwrap_or("")
.trim()
.to_string();
if name.is_empty() {
return Err(format!("items[{i}].name is empty"));
}
let source = obj
.get("source")
.and_then(|v| v.as_str())
.map(str::trim)
.filter(|s| !s.is_empty())
.map(str::to_string);
out.push(BatchItem { name, source });
}
reject_duplicates(&out)?;
return Ok(out);
}
let out: Vec<BatchItem> = names
.map(|a| {
a.iter()
.filter_map(|v| v.as_str())
.map(str::trim)
.filter(|s| !s.is_empty())
.map(|s| BatchItem { name: s.to_string(), source: None })
.collect()
})
.unwrap_or_default();
if out.is_empty() {
return Err(
"names cannot be empty — pass `names` ([\"a\", …]) or `items` \
([{name, source?}, …])"
.into(),
);
}
reject_duplicates(&out)?;
Ok(out)
}
fn reject_duplicates(items: &[BatchItem]) -> Result<(), String> {
let mut seen: std::collections::HashMap<String, usize> = std::collections::HashMap::new();
for (j, it) in items.iter().enumerate() {
let c = crate::subdomain::sanitize(&it.name);
if c.is_empty() || c != it.name.trim().to_ascii_lowercase() {
continue;
}
if let Some(first) = seen.insert(c.clone(), j) {
return Err(format!(
"duplicate name: item {first} (\"{}\") and item {j} (\"{}\") both \
normalize to \"{c}\" — list each name ONCE",
items[first].name, it.name
));
}
}
Ok(())
}
pub fn compile_source(source: &str, max_wasm: usize) -> Result<Vec<u8>, String> {
let wasm = crate::rustlite::compile(source)
.map_err(|e| format!("compile failed: {}", e.render(source)))?;
if wasm.len() > max_wasm {
return Err(format!(
"app wasm too large to publish: {} bytes (max {max_wasm})",
wasm.len()
));
}
Ok(wasm)
}
#[derive(Debug, Clone, PartialEq, Eq)]
#[non_exhaustive] pub enum ItemPlan {
Register,
UpdateInPlace,
CompileFailed(String),
PreSkipped(String),
}
#[derive(Debug, Clone, PartialEq, Eq)]
#[non_exhaustive] pub enum ItemState {
Registered,
UpdateInPlace,
CompileFailed(String),
Skipped(String),
Failed,
Unconfirmed,
Unattempted,
}
pub fn register_set(plan: &[ItemPlan]) -> Vec<usize> {
plan.iter()
.enumerate()
.filter(|(_, p)| matches!(p, ItemPlan::Register))
.map(|(i, _)| i)
.collect()
}
pub fn item_states(
plan: &[ItemPlan],
fold: &crate::relay_chunk::BatchFold,
cleaned: &[String],
registered: &[String],
) -> Vec<ItemState> {
let reg = register_set(plan);
let mut states: Vec<ItemState> = plan
.iter()
.map(|p| match p {
ItemPlan::Register => ItemState::Unattempted,
ItemPlan::UpdateInPlace => ItemState::UpdateInPlace,
ItemPlan::CompileFailed(e) => ItemState::CompileFailed(e.clone()),
ItemPlan::PreSkipped(r) => ItemState::Skipped(r.clone()),
})
.collect();
for &pos in &fold.landed {
let idx = reg[pos];
states[idx] = if registered.iter().any(|n| n == &cleaned[idx]) {
ItemState::Registered
} else {
ItemState::Skipped("taken or invalid — skipped at registration".into())
};
}
for &pos in &fold.failed {
states[reg[pos]] = ItemState::Failed;
}
for &pos in &fold.unconfirmed {
states[reg[pos]] = ItemState::Unconfirmed;
}
states
}
pub fn input_schema() -> serde_json::Value {
serde_json::json!({
"type": "object",
"properties": {
"names": {
"type": "array",
"items": { "type": "string" },
"description": "Name-only registrations, e.g. [\"alice\",\"bob\"] -> \
alice.localharness.xyz, bob.localharness.xyz. Each: 3-32 chars, \
lowercase letters, digits, hyphens. Already-taken or invalid names \
are skipped and reported back; listing the SAME name twice is a \
hard error. Give `names` OR `items`, never both. \
More than 7 are split across multiple sponsored txs automatically; \
at most 28 per call — split a bigger request into separate calls."
},
"items": {
"type": "array",
"description": "Register-AND-publish batch: one { name, source? } object \
per subdomain. An item WITH a rustlite `source` ALSO publishes it as \
that subdomain's app in the same call (the create_subdomain `source` \
behavior, batched). Every source compiles FIRST — a compile failure \
fails THAT item before any registration and spends nothing. A name \
you ALREADY OWN is updated in place (published, no re-register, no \
fee); the SAME name twice is a hard error. Give `items` OR `names`, \
never both; at most 28 items per call.",
"items": {
"type": "object",
"properties": {
"name": {
"type": "string",
"description": "The subdomain name (3-32 chars, lowercase \
letters, digits, hyphens)."
},
"source": {
"type": "string",
"description": "OPTIONAL rustlite cartridge source (the SAME \
dialect as run_cartridge / create_subdomain). Compiled up \
front; published OFF-CHAIN (free, no gas) as the \
subdomain's fullscreen public face once its name \
registers (or in place when you already own it)."
}
},
"required": ["name"]
}
},
"confirmation": {
"type": "string",
"description": "Single-use confirmation code. OMIT (or pass \"\") on \
the first call — it returns a challenge code shown to the owner \
(each registration costs real $LH on mainnet). List the names, ask \
the owner to TYPE the code in chat, then retry with it. Never \
invent it; only the platform issues it."
}
},
"required": []
})
}
#[cfg(test)]
mod tests {
use super::*;
use crate::relay_chunk::{fold_outcomes, ChunkOutcome};
use serde_json::json;
#[test]
fn parse_legacy_names_keeps_the_lenient_extraction_verbatim() {
let items = parse_items(&json!({"names": [" a ", "", 3, "bob"]})).unwrap();
assert_eq!(
items,
vec![
BatchItem { name: "a".into(), source: None },
BatchItem { name: "bob".into(), source: None },
]
);
for args in [json!({}), json!({"names": []}), json!({"names": ["", " "]})] {
assert!(parse_items(&args).unwrap_err().contains("names cannot be empty"));
}
}
#[test]
fn parse_items_union_accepts_sources_and_rejects_bad_shapes() {
let items = parse_items(&json!({
"items": [
{"name": " app1 ", "source": "fn f() -> i32 { 1 }"},
{"name": "bare"},
{"name": "blank-src", "source": " "},
]
}))
.unwrap();
assert_eq!(items[0].name, "app1");
assert_eq!(items[0].source.as_deref(), Some("fn f() -> i32 { 1 }"));
assert_eq!(items[1], BatchItem { name: "bare".into(), source: None });
assert_eq!(items[2].source, None);
assert!(parse_items(&json!({"items": ["oops"]})).unwrap_err().contains("items[0]"));
assert!(parse_items(&json!({"items": [{"source": "x"}]}))
.unwrap_err()
.contains("items[0].name"));
assert!(parse_items(&json!({"names": ["a"], "items": [{"name": "b"}]}))
.unwrap_err()
.contains("not both"));
assert!(parse_items(&json!({"names": [], "items": [{"name": "b"}]})).is_ok());
}
#[test]
fn parse_rejects_duplicate_cleaned_names_naming_both_indices() {
let err = parse_items(&json!({"names": ["alpha", "beta", "alpha"]})).unwrap_err();
assert!(err.contains("item 0") && err.contains("item 2"), "{err}");
assert!(err.contains("\"alpha\""), "{err}");
let err = parse_items(&json!({
"items": [{"name": "Alpha", "source": "fn f() -> i32 { 1 }"}, {"name": " alpha "}]
}))
.unwrap_err();
assert!(err.contains("item 0") && err.contains("item 1"), "{err}");
assert!(err.contains("\"alpha\""), "{err}");
let ok = parse_items(&json!({
"items": [{"name": "app", "source": "fn f() -> i32 { 1 }"}, {"name": "app!"}]
}));
assert!(ok.is_ok(), "{ok:?}");
assert!(parse_items(&json!({"names": ["!!!", "???", "ok-name"]})).is_ok());
}
#[test]
fn compile_first_partitions_good_bad_and_oversize_sources() {
let wasm = compile_source("fn helper(n: i32) -> i32 { n + 1 }", usize::MAX).unwrap();
assert!(!wasm.is_empty());
let err = compile_source("fn {", usize::MAX).unwrap_err();
assert!(err.starts_with("compile failed: "), "{err}");
let err = compile_source("fn helper(n: i32) -> i32 { n + 1 }", 4).unwrap_err();
assert!(err.contains("too large"), "{err}");
}
#[test]
fn item_states_scatters_the_subset_fold_onto_the_full_list() {
let plan = vec![
ItemPlan::Register,
ItemPlan::CompileFailed("compile failed: LH0001".into()),
ItemPlan::Register,
ItemPlan::UpdateInPlace,
ItemPlan::PreSkipped("owned by 0xabc, not you".into()),
ItemPlan::Register,
ItemPlan::Register,
];
assert_eq!(register_set(&plan), vec![0, 2, 5, 6]);
let cleaned: Vec<String> =
["zero", "one", "two", "three", "four", "five", "six"]
.iter()
.map(|s| s.to_string())
.collect();
let ranges = vec![0..2, 2..3, 3..4];
let fold = fold_outcomes(
&ranges,
&[ChunkOutcome::Landed("0xa".into()), ChunkOutcome::Failed("boom".into())],
);
let states = item_states(&plan, &fold, &cleaned, &["zero".to_string()]);
assert_eq!(states[0], ItemState::Registered);
assert_eq!(states[1], ItemState::CompileFailed("compile failed: LH0001".into()));
assert_eq!(
states[2],
ItemState::Skipped("taken or invalid — skipped at registration".into())
);
assert_eq!(states[3], ItemState::UpdateInPlace);
assert_eq!(states[4], ItemState::Skipped("owned by 0xabc, not you".into()));
assert_eq!(states[5], ItemState::Failed);
assert_eq!(states[6], ItemState::Unattempted);
}
#[test]
fn item_states_marks_unconfirmed_receipt_timeouts() {
let plan = vec![ItemPlan::Register, ItemPlan::Register];
let fold = fold_outcomes(&[0..1, 1..2], &[ChunkOutcome::Unconfirmed("0xbeef".into())]);
let states = item_states(
&plan,
&fold,
&["a".to_string(), "b".to_string()],
&[],
);
assert_eq!(states, vec![ItemState::Unconfirmed, ItemState::Unattempted]);
}
#[test]
fn input_schema_is_gemini_safe_and_carries_the_union() {
fn walk(v: &serde_json::Value) {
match v {
serde_json::Value::Object(map) => {
for banned in
["oneOf", "anyOf", "allOf", "additionalProperties", "$ref", "$schema"]
{
assert!(map.get(banned).is_none(), "banned key {banned}");
}
if let Some(t) = map.get("type") {
assert!(t.is_string(), "union type: {t}");
}
map.values().for_each(walk);
}
serde_json::Value::Array(a) => a.iter().for_each(walk),
_ => {}
}
}
let s = input_schema();
walk(&s);
assert_eq!(s["required"], json!([]));
assert_eq!(s["properties"]["names"]["type"], "array");
assert_eq!(s["properties"]["items"]["items"]["type"], "object");
assert_eq!(s["properties"]["items"]["items"]["required"], json!(["name"]));
let n = crate::relay_chunk::MAX_BATCH_ITEMS.to_string();
for arm in ["names", "items"] {
let d = s["properties"][arm]["description"].as_str().unwrap();
assert!(
d.contains(&format!("at most {n}")),
"{arm} description dropped the live batch bound {n}"
);
}
}
}