diff --git a/.gitignore b/.gitignore index b2c30b0a..03f07a72 100644 --- a/.gitignore +++ b/.gitignore @@ -5,3 +5,4 @@ src/new/includes.rs src/new/templates/**/Cargo.lock src/new/templates/**/*.wasm src/new/templates/**/*.zip +**/.DS_Store diff --git a/Cargo.lock b/Cargo.lock index bd83640a..38c16e55 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2282,7 +2282,7 @@ dependencies = [ [[package]] name = "kit" -version = "2.0.0" +version = "3.0.0" dependencies = [ "alloy", "alloy-sol-macro", diff --git a/Cargo.toml b/Cargo.toml index 462ef056..72a7c7b5 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,7 +1,7 @@ [package] name = "kit" authors = ["Sybil Technologies AG"] -version = "2.0.0" +version = "3.0.0" edition = "2021" description = "Development toolkit for Hyperware" homepage = "https://hyperware.ai" @@ -52,8 +52,7 @@ semver = "1.0" serde = { version = "1.0", features = ["derive"] } serde_json = "1.0" sha2 = "0.10.8" -syn = { version = "2.0", features = ["full", "visit", "extra-traits"] } -#syn = { version = "2.0", features = ["full", "visit"] } +syn = { version = "2.0", features = ["full", "visit", "parsing", "extra-traits"] } thiserror = "1.0" tokio = { version = "1.28", features = [ "macros", diff --git a/build.rs b/build.rs index 5c3ae138..1bdf8338 100644 --- a/build.rs +++ b/build.rs @@ -50,7 +50,10 @@ fn visit_dirs(dir: &Path, output_buffer: &mut Vec) -> io::Result<()> { let path = entry.path(); if path.is_dir() { let dir_name = path.file_name().and_then(|s| s.to_str()); - if dir_name == Some("home") || dir_name == Some("target") { + if dir_name == Some("home") + || dir_name == Some("target") + || dir_name == Some(".mypy_cache") + { continue; } visit_dirs(&path, output_buffer)?; @@ -106,6 +109,9 @@ fn add_branch_name(repo: &git2::Repository) -> anyhow::Result<()> { } fn main() -> anyhow::Result<()> { + // Always run this script + println!("cargo:rerun-if-changed=NULL"); + make_new_includes()?; // write version info into binary diff --git a/src/boot_fake_node/mod.rs b/src/boot_fake_node/mod.rs index 07f8549f..c309b034 100644 --- a/src/boot_fake_node/mod.rs +++ b/src/boot_fake_node/mod.rs @@ -405,6 +405,7 @@ pub fn run_runtime( format!("{port}"), "--verbosity".into(), format!("{verbosity}"), + "--expose-local".into(), ]; if !args.is_empty() { diff --git a/src/build/caller_utils_generator.rs b/src/build/caller_utils_generator.rs new file mode 100644 index 00000000..d882f9ab --- /dev/null +++ b/src/build/caller_utils_generator.rs @@ -0,0 +1,1024 @@ +use std::collections::HashMap; +use std::fs; +use std::path::{Path, PathBuf}; + +use color_eyre::{ + eyre::{bail, eyre, WrapErr}, + Result, +}; +use tracing::{debug, info, instrument, warn}; + +use toml::Value; +use walkdir::WalkDir; + +// Convert kebab-case to snake_case +pub fn to_snake_case(s: &str) -> String { + s.replace('-', "_") +} + +// Convert kebab-case to PascalCase +pub fn to_pascal_case(s: &str) -> String { + let parts = s.split('-'); + let mut result = String::new(); + + for part in parts { + if !part.is_empty() { + let mut chars = part.chars(); + if let Some(first_char) = chars.next() { + result.push(first_char.to_uppercase().next().unwrap()); + result.extend(chars); + } + } + } + + result +} + +// Find the world name in the world WIT file, prioritizing types-prefixed worlds +#[instrument(level = "trace", skip_all)] +fn find_world_names(api_dir: &Path) -> Result> { + debug!(dir = ?api_dir, "Looking for world names..."); + let mut world_names = Vec::new(); + + // Look for world definition files + for entry in WalkDir::new(api_dir) + .max_depth(1) + .into_iter() + .filter_map(Result::ok) + { + let path = entry.path(); + + if path.is_file() && path.extension().map_or(false, |ext| ext == "wit") { + if let Ok(content) = fs::read_to_string(path) { + if content.contains("world ") { + debug!(file = %path.display(), "Analyzing potential world definition file"); + + // Extract the world name + let lines: Vec<&str> = content.lines().collect(); + + if let Some(world_line) = + lines.iter().find(|line| line.trim().starts_with("world ")) + { + debug!(line = %world_line, "Found world line"); + + if let Some(world_name) = world_line.trim().split_whitespace().nth(1) { + let clean_name = world_name.trim_end_matches(" {"); + debug!(name = %clean_name, "Extracted potential world name"); + + // Check if this is a types-prefixed world + if clean_name.starts_with("types-") { + world_names.push(clean_name.to_string()); + debug!(name = %clean_name, "Found types-prefixed world"); + } + } + } + } + } + } + } + + if world_names.is_empty() { + bail!("No world name found in any WIT file. Cannot generate caller-utils without a world name.") + } + Ok(world_names) +} + +// Convert WIT type to Rust type - IMPROVED with more Rust primitives +fn wit_type_to_rust(wit_type: &str) -> String { + match wit_type { + // Integer types + "s8" => "i8".to_string(), + "u8" => "u8".to_string(), + "s16" => "i16".to_string(), + "u16" => "u16".to_string(), + "s32" => "i32".to_string(), + "u32" => "u32".to_string(), + "s64" => "i64".to_string(), + "u64" => "u64".to_string(), + // Floating point types + "f32" => "f32".to_string(), + "f64" => "f64".to_string(), + // Other primitive types + "string" => "String".to_string(), + "str" => "&str".to_string(), + "char" => "char".to_string(), + "bool" => "bool".to_string(), + "_" => "()".to_string(), + // Special types + "address" => "WitAddress".to_string(), + // Collection types with generics + t if t.starts_with("list<") => { + let inner_type = &t[5..t.len() - 1]; + format!("Vec<{}>", wit_type_to_rust(inner_type)) + } + t if t.starts_with("option<") => { + let inner_type = &t[7..t.len() - 1]; + format!("Option<{}>", wit_type_to_rust(inner_type)) + } + t if t.starts_with("result<") => { + let inner_part = &t[7..t.len() - 1]; + if let Some(comma_pos) = inner_part.find(',') { + let ok_type = &inner_part[..comma_pos].trim(); + let err_type = &inner_part[comma_pos + 1..].trim(); + format!( + "Result<{}, {}>", + wit_type_to_rust(ok_type), + wit_type_to_rust(err_type) + ) + } else { + format!("Result<{}, ()>", wit_type_to_rust(inner_part)) + } + } + t if t.starts_with("tuple<") => { + let inner_types = &t[6..t.len() - 1]; + let rust_types: Vec = inner_types + .split(", ") + .map(|t| wit_type_to_rust(t)) + .collect(); + format!("({})", rust_types.join(", ")) + } + // Custom types (in kebab-case) need to be converted to PascalCase + _ => to_pascal_case(wit_type).to_string(), + } +} + +// Generate default value for Rust type - IMPROVED with additional types +fn generate_default_value(rust_type: &str) -> String { + match rust_type { + // Integer types + "i8" | "u8" | "i16" | "u16" | "i32" | "u32" | "i64" | "u64" | "isize" | "usize" => { + "0".to_string() + } + // Floating point types + "f32" | "f64" => "0.0".to_string(), + // String types + "String" => "String::new()".to_string(), + "&str" => "\"\"".to_string(), + // Other primitive types + "bool" => "false".to_string(), + "char" => "'\\0'".to_string(), + "()" => "()".to_string(), + // Collection types + t if t.starts_with("Vec<") => "Vec::new()".to_string(), + t if t.starts_with("Option<") => "None".to_string(), + t if t.starts_with("Result<") => { + // For Result, default to Ok with the default value of the success type + if let Some(success_type_end) = t.find(',') { + let success_type = &t[7..success_type_end]; + format!("Ok({})", generate_default_value(success_type)) + } else { + "Ok(())".to_string() + } + } + //t if t.starts_with("HashMap<") => "HashMap::new()".to_string(), + t if t.starts_with("(") => { + // Generate default tuple with default values for each element + let inner_part = t.trim_start_matches('(').trim_end_matches(')'); + let parts: Vec<_> = inner_part.split(", ").collect(); + let default_values: Vec<_> = parts + .iter() + .map(|part| generate_default_value(part)) + .collect(); + format!("({})", default_values.join(", ")) + } + // For custom types, assume they implement Default + _ => format!("{}::default()", rust_type), + } +} + +// Structure to represent a field in a WIT signature struct +#[derive(Debug)] +struct SignatureField { + name: String, + wit_type: String, +} + +// Structure to represent a WIT signature struct +#[derive(Debug)] +struct SignatureStruct { + function_name: String, + attr_type: String, + fields: Vec, +} + +// Find all interface imports in the world WIT file +#[instrument(level = "trace", skip_all)] +fn find_interfaces_in_world(api_dir: &Path) -> Result> { + debug!(dir = ?api_dir, "Finding interface imports in world definitions"); + let mut interfaces = Vec::new(); + + // Find world definition files + for entry in WalkDir::new(api_dir) + .max_depth(1) + .into_iter() + .filter_map(Result::ok) + { + let path = entry.path(); + + if path.is_file() && path.extension().map_or(false, |ext| ext == "wit") { + if let Ok(content) = fs::read_to_string(path) { + if content.contains("world ") { + debug!(file = %path.display(), "Analyzing world definition file for imports"); + + // Extract import statements + for line in content.lines() { + let line = line.trim(); + if line.starts_with("import ") && line.ends_with(";") { + let interface = line + .trim_start_matches("import ") + .trim_end_matches(";") + .trim(); + + interfaces.push(interface.to_string()); + debug!(interface = %interface, "Found interface import"); + } + } + } + } + } + } + debug!(count = interfaces.len(), interfaces = ?interfaces, "Found interface imports"); + Ok(interfaces) +} + +// Parse WIT file to extract function signatures and type definitions +#[instrument(level = "trace", skip_all)] +fn parse_wit_file(file_path: &Path) -> Result<(Vec, Vec)> { + debug!(file = %file_path.display(), "Parsing WIT file"); + + let content = fs::read_to_string(file_path) + .with_context(|| format!("Failed to read WIT file: {}", file_path.display()))?; + + let mut signatures = Vec::new(); + let mut type_names = Vec::new(); + + // Simple parser for WIT files to extract record definitions and types + let lines: Vec<_> = content.lines().collect(); + let mut i = 0; + + while i < lines.len() { + let line = lines[i].trim(); + + // Look for record definitions that aren't signature structs + if line.starts_with("record ") && !line.contains("-signature-") { + let record_name = line + .trim_start_matches("record ") + .trim_end_matches(" {") + .trim(); + debug!(name = %record_name, "Found type definition (record)"); + type_names.push(record_name.to_string()); + } + // Look for variant definitions (enums) + else if line.starts_with("variant ") { + let variant_name = line + .trim_start_matches("variant ") + .trim_end_matches(" {") + .trim(); + debug!(name = %variant_name, "Found type definition (variant)"); + type_names.push(variant_name.to_string()); + } + // Look for signature record definitions + else if line.starts_with("record ") && line.contains("-signature-") { + let record_name = line + .trim_start_matches("record ") + .trim_end_matches(" {") + .trim(); + debug!(name = %record_name, "Found signature record"); + + // Extract function name and attribute type + let parts: Vec<_> = record_name.split("-signature-").collect(); + if parts.len() != 2 { + warn!(name = %record_name, "Unexpected signature record name format, skipping"); + i += 1; + continue; + } + + let function_name = parts[0].to_string(); + let attr_type = parts[1].to_string(); + debug!(function = %function_name, attr_type = %attr_type, "Extracted function name and type"); + + // Parse fields + let mut fields = Vec::new(); + i += 1; + + while i < lines.len() && !lines[i].trim().starts_with("}") { + let field_line = lines[i].trim(); + + // Skip comments and empty lines + if field_line.starts_with("//") || field_line.is_empty() { + i += 1; + continue; + } + + // Parse field definition + let field_parts: Vec<_> = field_line.split(':').collect(); + if field_parts.len() == 2 { + let field_name = field_parts[0].trim().to_string(); + let field_type = field_parts[1].trim().trim_end_matches(',').to_string(); + + debug!(name = %field_name, wit_type = %field_type, "Found field"); + fields.push(SignatureField { + name: field_name, + wit_type: field_type, + }); + } + + i += 1; + } + + signatures.push(SignatureStruct { + function_name, + attr_type, + fields, + }); + } + + i += 1; + } + + debug!( + file = %file_path.display(), + signatures = signatures.len(), + types = type_names.len(), + "Finished parsing WIT file" + ); + Ok((signatures, type_names)) +} + +// Generate a Rust async function from a signature struct +fn generate_async_function(signature: &SignatureStruct) -> String { + // Convert function name from kebab-case to snake_case + let snake_function_name = to_snake_case(&signature.function_name); + + // Get pascal case version for the JSON request format + let pascal_function_name = to_pascal_case(&signature.function_name); + + // Function full name with attribute type + let full_function_name = format!("{}_{}_rpc", snake_function_name, signature.attr_type); + debug!(name = %full_function_name, "Generating function stub"); + + // Extract parameters and return type + let mut params = Vec::new(); + let mut param_names = Vec::new(); + let mut return_type = "()".to_string(); + let mut target_param = ""; + + for field in &signature.fields { + let field_name_snake = to_snake_case(&field.name); + let rust_type = wit_type_to_rust(&field.wit_type); + debug!(field = %field.name, wit_type = %field.wit_type, rust_type = %rust_type, "Processing field"); + + if field.name == "target" { + if field.wit_type == "string" { + target_param = "&str"; + } else { + // Use hyperware_process_lib::Address instead of WitAddress + target_param = "&Address"; + } + } else if field.name == "returning" { + return_type = rust_type; + debug!(return_type = %return_type, "Identified return type"); + } else { + params.push(format!("{}: {}", field_name_snake, rust_type)); + param_names.push(field_name_snake); + debug!(param_name = param_names.last().unwrap(), "Added parameter"); + } + } + + // First parameter is always target + let all_params = if target_param.is_empty() { + warn!( + "No 'target' parameter found in signature for {}", + full_function_name + ); + params.join(", ") + } else { + format!( + "target: {}{}", + target_param, + if params.is_empty() { "" } else { ", " } + ) + ¶ms.join(", ") + }; + + // Wrap the return type in a Result<_, AppSendError> + let wrapped_return_type = format!("Result<{}, AppSendError>", return_type); + + // For HTTP endpoints, generate commented-out implementation + if signature.attr_type == "http" { + debug!("Generating commented-out stub for HTTP endpoint"); + let default_value = generate_default_value(&return_type); + + // Add underscore prefix to all parameters for HTTP stubs + let all_params_with_underscore = if target_param.is_empty() { + params + .iter() + .map(|param| { + let parts: Vec<&str> = param.split(':').collect(); + if parts.len() == 2 { + format!("_{}: {}", parts[0], parts[1]) + } else { + warn!(param = %param, "Could not parse parameter for underscore prefix"); + format!("_{}", param) + } + }) + .collect::>() + .join(", ") + } else { + let target_with_underscore = format!("_target: {}", target_param); + if params.is_empty() { + target_with_underscore + } else { + let params_with_underscore = params + .iter() + .map(|param| { + let parts: Vec<&str> = param.split(':').collect(); + if parts.len() == 2 { + format!("_{}: {}", parts[0], parts[1]) + } else { + warn!(param = %param, "Could not parse parameter for underscore prefix"); + format!("_{}", param) + } + }) + .collect::>() + .join(", "); + format!("{}, {}", target_with_underscore, params_with_underscore) + } + }; + + return format!( + "// /// Generated stub for `{}` {} RPC call\n// /// HTTP endpoint - uncomment to implement\n// pub async fn {}({}) -> {} {{\n// // TODO: Implement HTTP endpoint\n// Ok({})\n// }}", + signature.function_name, + signature.attr_type, + full_function_name, + all_params_with_underscore, + wrapped_return_type, + default_value + ); + } + + // Format JSON parameters correctly + let json_params = if param_names.is_empty() { + // No parameters case + debug!("Generating JSON with no parameters"); + format!("json!({{\"{}\" : {{}}}})", pascal_function_name) + } else if param_names.len() == 1 { + // Single parameter case + debug!(param = %param_names[0], "Generating JSON with single parameter"); + format!( + "json!({{\"{}\": {}}})", + pascal_function_name, param_names[0] + ) + } else { + // Multiple parameters case - use tuple format + debug!(params = ?param_names, "Generating JSON with multiple parameters (tuple)"); + format!( + "json!({{\"{}\": ({})}})", + pascal_function_name, + param_names.join(", ") + ) + }; + + // Generate function with implementation using send + debug!("Generating standard RPC stub implementation"); + format!( + "/// Generated stub for `{}` {} RPC call\npub async fn {}({}) -> {} {{\n let body = {};\n let body = serde_json::to_vec(&body).unwrap();\n let request = Request::to(target)\n .body(body);\n send::<{}>(request).await\n}}", + signature.function_name, + signature.attr_type, + full_function_name, + all_params, + wrapped_return_type, + json_params, + return_type + ) +} + +// Create the caller-utils crate with a single lib.rs file +#[instrument(level = "trace", skip_all)] +fn create_caller_utils_crate(api_dir: &Path, base_dir: &Path) -> Result<()> { + // Path to the new crate + let caller_utils_dir = base_dir.join("target").join("caller-utils"); + debug!( + path = %caller_utils_dir.display(), + "Creating caller-utils crate" + ); + + // Create directories + fs::create_dir_all(&caller_utils_dir)?; + fs::create_dir_all(caller_utils_dir.join("src"))?; + debug!("Created project directory structure"); + + // Get hyperware_app_common dependency from the process's Cargo.toml + let hyperware_dep = get_hyperware_app_common_dependency(base_dir)?; + debug!("Got hyperware_app_common dependency: {}", hyperware_dep); + + // Create Cargo.toml with updated dependencies + let cargo_toml = format!( + r#"[package] +name = "caller-utils" +version = "0.1.0" +edition = "2021" +publish = false + +[dependencies] +anyhow = "1.0" +process_macros = "0.1.0" +futures-util = "0.3" +serde = {{ version = "1.0", features = ["derive"] }} +serde_json = "1.0" +hyperware_app_common = {} +once_cell = "1.20.2" +futures = "0.3" +uuid = {{ version = "1.0" }} +wit-bindgen = "0.41.0" + +[lib] +crate-type = ["cdylib", "lib"] +"#, + hyperware_dep + ); + + fs::write(caller_utils_dir.join("Cargo.toml"), cargo_toml) + .with_context(|| "Failed to write caller-utils Cargo.toml")?; + + debug!("Created Cargo.toml for caller-utils"); + + // Get the world name (preferably the types- version) + let world_names = find_world_names(api_dir)?; + debug!("Using world names for code generation: {:?}", world_names); + let world_name = if world_names.len() == 0 { + "" + } else if world_names.len() == 1 { + &world_names[0] + } else { + let path = api_dir.join("types.wit"); + let mut content = "world types {\n".to_string(); + for world_name in world_names { + content.push_str(&format!(" include {world_name};\n")); + } + content.push_str("}\n"); + fs::write(&path, &content)?; + "types" + }; + + // Get all interfaces from the world file + let interface_imports = find_interfaces_in_world(api_dir)?; + + // Store all types from each interface + let mut interface_types: HashMap> = HashMap::new(); + + // Find all WIT files in the api directory to generate stubs + let mut wit_files = Vec::new(); + for entry in WalkDir::new(api_dir) + .max_depth(1) + .into_iter() + .filter_map(Result::ok) + { + let path = entry.path(); + if path.is_file() && path.extension().map_or(false, |ext| ext == "wit") { + // Exclude world definition files + if let Ok(content) = fs::read_to_string(path) { + if !content.contains("world ") { + debug!(file = %path.display(), "Adding WIT file for parsing"); + wit_files.push(path.to_path_buf()); + } else { + debug!(file = %path.display(), "Skipping world definition WIT file"); + } + } + } + } + + debug!( + count = wit_files.len(), + "Found WIT interface files for stub generation" + ); + + // Generate content for each module and collect types + let mut module_contents = HashMap::::new(); + + for wit_file in &wit_files { + // Extract the interface name from the file name + let interface_name = wit_file.file_stem().unwrap().to_string_lossy(); + let snake_interface_name = to_snake_case(&interface_name); + + debug!( + interface = %interface_name, module = %snake_interface_name, file = %wit_file.display(), + "Processing interface" + ); + + // Parse the WIT file to extract signature structs and types + match parse_wit_file(wit_file) { + Ok((signatures, types)) => { + // Store types for this interface + interface_types.insert(interface_name.to_string(), types); + + if signatures.is_empty() { + debug!(file = %wit_file.display(), "No signature records found, skipping module generation for this file."); + continue; + } + + // Generate module content + let mut mod_content = String::new(); + + // Add function implementations + for signature in &signatures { + let function_impl = generate_async_function(signature); + mod_content.push_str(&function_impl); + mod_content.push_str("\n\n"); + } + + // Store the module content + module_contents.insert(snake_interface_name.clone(), mod_content); + + debug!( + interface = %interface_name, module = %snake_interface_name.as_str(), count = signatures.len(), + "Generated module content" + ); + } + Err(e) => { + warn!(file = %wit_file.display(), error = %e, "Error parsing WIT file, skipping"); + } + } + } + + // Create import statements for each interface using "hyperware::process::{interface_name}::*" + // Use a HashSet to track which interfaces we've already processed to avoid duplicates + let mut processed_interfaces = std::collections::HashSet::new(); + let mut interface_use_statements = Vec::new(); + + for interface_name in &interface_imports { + // Convert to snake case for module name + let snake_interface_name = to_snake_case(interface_name); + + // Only add the import if we haven't processed this interface yet + if processed_interfaces.insert(snake_interface_name.clone()) { + // Create wildcard import for this interface + interface_use_statements.push(format!( + "pub use crate::hyperware::process::{}::*;", + snake_interface_name + )); + } + } + + // Create single lib.rs with all modules inline + let mut lib_rs = String::new(); + + lib_rs.push_str("wit_bindgen::generate!({\n"); + lib_rs.push_str(" path: \"target/wit\",\n"); + lib_rs.push_str(&format!(" world: \"{}\",\n", world_name)); + lib_rs.push_str(" generate_unused_types: true,\n"); + lib_rs.push_str(" additional_derives: [serde::Deserialize, serde::Serialize, process_macros::SerdeJsonInto],\n"); + lib_rs.push_str("});\n\n"); + + lib_rs.push_str("/// Generated caller utilities for RPC function stubs\n\n"); + + // Add global imports + lib_rs.push_str("pub use hyperware_app_common::AppSendError;\n"); + lib_rs.push_str("pub use hyperware_app_common::send;\n"); + lib_rs.push_str("use hyperware_app_common::hyperware_process_lib as hyperware_process_lib;\n"); + lib_rs.push_str("pub use hyperware_process_lib::{Address, Request};\n"); + lib_rs.push_str("use serde_json::json;\n\n"); + + // Add interface use statements + if !interface_use_statements.is_empty() { + lib_rs.push_str("// Import types from each interface\n"); + for use_stmt in interface_use_statements { + lib_rs.push_str(&format!("{}\n", use_stmt)); + } + lib_rs.push_str("\n"); + } + + // Add all modules with their content + for (module_name, module_content) in module_contents { + lib_rs.push_str(&format!( + "/// Generated RPC stubs for the {} interface\n", + module_name + )); + lib_rs.push_str(&format!("pub mod {} {{\n", module_name)); + lib_rs.push_str(" use crate::*;\n\n"); + lib_rs.push_str(&format!(" {}\n", module_content.replace("\n", "\n "))); + lib_rs.push_str("}\n\n"); + } + + // Write lib.rs + let lib_rs_path = caller_utils_dir.join("src").join("lib.rs"); + debug!("Writing generated code to {}", lib_rs_path.display()); + + fs::write(&lib_rs_path, lib_rs) + .with_context(|| format!("Failed to write lib.rs: {}", lib_rs_path.display()))?; + + // Create target/wit directory and copy all WIT files + let target_wit_dir = caller_utils_dir.join("target").join("wit"); + debug!("Creating directory: {}", target_wit_dir.display()); + + // Remove the directory if it exists to ensure clean state + if target_wit_dir.exists() { + debug!("Removing existing target/wit directory"); + fs::remove_dir_all(&target_wit_dir)?; + } + + fs::create_dir_all(&target_wit_dir)?; + + // Copy all WIT files to target/wit + for entry in WalkDir::new(api_dir) + .max_depth(1) + .into_iter() + .filter_map(Result::ok) + { + let path = entry.path(); + if path.is_file() && path.extension().map_or(false, |ext| ext == "wit") { + let file_name = path.file_name().unwrap(); + let target_path = target_wit_dir.join(file_name); + fs::copy(path, &target_path).with_context(|| { + format!( + "Failed to copy {} to {}", + path.display(), + target_path.display() + ) + })?; + debug!( + "Copied {} to target/wit directory", + file_name.to_string_lossy() + ); + } + } + + Ok(()) +} + +// Format a TOML dependency value into an inline table string +fn format_toml_dependency(dep: &Value) -> Option { + match dep { + Value::Table(table) => { + let fields = [ + ("git", None), + ("rev", None), + ("branch", None), + ("tag", None), + ("version", None), + ("path", None), + ( + "features", + Some(|v: &Value| -> Option { + Some( + v.as_array()? + .iter() + .filter_map(|f| f.as_str()) + .map(|f| format!("\"{}\"", f)) + .collect::>() + .join(", "), + ) + }), + ), + ]; + + let parts: Vec = fields + .iter() + .filter_map(|(key, formatter)| { + let value = table.get(*key)?; + if let Some(format_fn) = formatter { + Some(format!("{} = [{}]", key, format_fn(value)?)) + } else { + Some(format!("{} = \"{}\"", key, value.as_str()?)) + } + }) + .collect(); + + Some(format!("{{ {} }}", parts.join(", "))) + } + Value::String(s) => Some(format!("\"{}\"", s)), + _ => None, + } +} + +// Read and parse a Cargo.toml file +fn read_cargo_toml(path: &Path) -> Result { + let content = fs::read_to_string(path) + .with_context(|| format!("Failed to read Cargo.toml: {}", path.display()))?; + content + .parse() + .with_context(|| format!("Failed to parse Cargo.toml: {}", path.display())) +} + +// Get hyperware_app_common dependency from the process Cargo.toml files +#[instrument(level = "trace", skip_all)] +fn get_hyperware_app_common_dependency(base_dir: &Path) -> Result { + const DEFAULT_DEP: &str = + r#"{ git = "https://github.com/hyperware-ai/hyperprocess-macro", rev = "4c944b2" }"#; + + // Read workspace members + let workspace_toml = read_cargo_toml(&base_dir.join("Cargo.toml"))?; + let members = workspace_toml + .get("workspace") + .and_then(|w| w.get("members")) + .and_then(|m| m.as_array()) + .ok_or_else(|| eyre!("No workspace.members found in Cargo.toml"))?; + + // Collect hyperware_app_common dependencies from all process members + let mut found_deps = HashMap::new(); + + for member in members.iter().filter_map(|m| m.as_str()) { + // Skip generated directories + if member.starts_with("target/") { + continue; + } + + let member_cargo_path = base_dir.join(member).join("Cargo.toml"); + if !member_cargo_path.exists() { + debug!( + "Member Cargo.toml not found: {}", + member_cargo_path.display() + ); + continue; + } + + let member_toml = read_cargo_toml(&member_cargo_path)?; + + if let Some(dep) = member_toml + .get("dependencies") + .and_then(|d| d.get("hyperware_app_common")) + .and_then(format_toml_dependency) + { + debug!("Found hyperware_app_common in {}: {}", member, dep); + found_deps.insert(member.to_string(), dep); + } + } + + // Handle results + match found_deps.len() { + 0 => { + warn!("No hyperware_app_common dependencies found in any process, using default"); + Ok(DEFAULT_DEP.to_string()) + } + 1 => { + let dep = found_deps.values().next().unwrap(); + info!("Using hyperware_app_common dependency: {}", dep); + Ok(dep.clone()) + } + _ => { + // Ensure all dependencies match + let mut deps_iter = found_deps.values(); + let first_dep = deps_iter.next().unwrap(); + + for dep in deps_iter { + if dep != first_dep { + let (first_process, _) = + found_deps.iter().find(|(_, d)| *d == first_dep).unwrap(); + let (conflict_process, _) = found_deps.iter().find(|(_, d)| *d == dep).unwrap(); + bail!( + "Conflicting hyperware_app_common versions found:\n Process '{}': {}\n Process '{}': {}\nAll processes must use the same version.", + first_process, first_dep, conflict_process, dep + ); + } + } + + info!("Using hyperware_app_common dependency: {}", first_dep); + Ok(first_dep.clone()) + } + } +} + +// Update workspace Cargo.toml to include the caller-utils crate +#[instrument(level = "trace", skip_all)] +fn update_workspace_cargo_toml(base_dir: &Path) -> Result<()> { + let workspace_cargo_toml = base_dir.join("Cargo.toml"); + debug!( + path = %workspace_cargo_toml.display(), + "Updating workspace Cargo.toml" + ); + + if !workspace_cargo_toml.exists() { + warn!( + path = %workspace_cargo_toml.display(), + "Workspace Cargo.toml not found, skipping update." + ); + return Ok(()); + } + + let content = fs::read_to_string(&workspace_cargo_toml).with_context(|| { + format!( + "Failed to read workspace Cargo.toml: {}", + workspace_cargo_toml.display() + ) + })?; + + // Parse the TOML content + let mut parsed_toml: Value = content + .parse() + .with_context(|| "Failed to parse workspace Cargo.toml")?; + + // Check if there's a workspace section + if let Some(workspace) = parsed_toml.get_mut("workspace") { + if let Some(members) = workspace.get_mut("members") { + if let Some(members_array) = members.as_array_mut() { + // Check if caller-utils is already in the members list + let caller_utils_exists = members_array + .iter() + .any(|m| m.as_str().map_or(false, |s| s == "target/caller-utils")); + + if !caller_utils_exists { + members_array.push(Value::String("target/caller-utils".to_string())); + + // Write back the updated TOML + let updated_content = toml::to_string_pretty(&parsed_toml) + .with_context(|| "Failed to serialize updated workspace Cargo.toml")?; + + fs::write(&workspace_cargo_toml, updated_content).with_context(|| { + format!( + "Failed to write updated workspace Cargo.toml: {}", + workspace_cargo_toml.display() + ) + })?; + + debug!("Successfully updated workspace Cargo.toml"); + } else { + debug!( + "Workspace Cargo.toml already up-to-date regarding caller-utils member." + ); + } + } + } + } + + Ok(()) +} + +// Add caller-utils as a dependency to hyperware:process crates +#[instrument(level = "trace", skip_all)] +pub fn add_caller_utils_to_projects(projects: &[PathBuf]) -> Result<()> { + for project_path in projects { + let cargo_toml_path = project_path.join("Cargo.toml"); + debug!( + project = ?project_path.file_name().unwrap_or_default(), + path = %cargo_toml_path.display(), + "Processing project" + ); + + let content = fs::read_to_string(&cargo_toml_path).with_context(|| { + format!( + "Failed to read project Cargo.toml: {}", + cargo_toml_path.display() + ) + })?; + + let mut parsed_toml: Value = content.parse().with_context(|| { + format!( + "Failed to parse project Cargo.toml: {}", + cargo_toml_path.display() + ) + })?; + + // Add caller-utils to dependencies if not already present + if let Some(dependencies) = parsed_toml.get_mut("dependencies") { + if let Some(deps_table) = dependencies.as_table_mut() { + if !deps_table.contains_key("caller-utils") { + deps_table.insert( + "caller-utils".to_string(), + Value::Table({ + let mut t = toml::map::Map::new(); + t.insert( + "path".to_string(), + Value::String("../target/caller-utils".to_string()), + ); + t + }), + ); + + // Write back the updated TOML + let updated_content = + toml::to_string_pretty(&parsed_toml).with_context(|| { + format!( + "Failed to serialize updated project Cargo.toml: {}", + cargo_toml_path.display() + ) + })?; + + fs::write(&cargo_toml_path, updated_content).with_context(|| { + format!( + "Failed to write updated project Cargo.toml: {}", + cargo_toml_path.display() + ) + })?; + + debug!(project = ?project_path.file_name().unwrap_or_default(), "Successfully added caller-utils dependency"); + } else { + debug!(project = ?project_path.file_name().unwrap_or_default(), "caller-utils dependency already exists"); + } + } + } + } + + Ok(()) +} + +// Create caller-utils crate and integrate with the workspace +#[instrument(level = "trace", skip_all)] +pub fn create_caller_utils(base_dir: &Path, api_dir: &Path) -> Result<()> { + // Step 1: Create the caller-utils crate + create_caller_utils_crate(api_dir, base_dir)?; + + // Step 2: Update workspace Cargo.toml + update_workspace_cargo_toml(base_dir)?; + + info!("Successfully created caller-utils and copied the imports"); + Ok(()) +} diff --git a/src/build/caller_utils_ts_generator.rs b/src/build/caller_utils_ts_generator.rs new file mode 100644 index 00000000..d72ab994 --- /dev/null +++ b/src/build/caller_utils_ts_generator.rs @@ -0,0 +1,654 @@ +use std::fs; +use std::path::Path; + +use color_eyre::{eyre::WrapErr, Result}; +use tracing::{debug, info, instrument, warn}; + +use walkdir::WalkDir; + +// Convert kebab-case to camelCase +pub fn to_camel_case(s: &str) -> String { + let parts: Vec<&str> = s.split('-').collect(); + if parts.is_empty() { + return String::new(); + } + + let mut result = parts[0].to_string(); + for part in &parts[1..] { + if !part.is_empty() { + let mut chars = part.chars(); + if let Some(first_char) = chars.next() { + result.push(first_char.to_uppercase().next().unwrap()); + result.extend(chars); + } + } + } + + result +} + +// Convert kebab-case to PascalCase +pub fn to_pascal_case(s: &str) -> String { + let parts = s.split('-'); + let mut result = String::new(); + + for part in parts { + if !part.is_empty() { + let mut chars = part.chars(); + if let Some(first_char) = chars.next() { + result.push(first_char.to_uppercase().next().unwrap()); + result.extend(chars); + } + } + } + + result +} + +// Convert WIT type to TypeScript type +fn wit_type_to_typescript(wit_type: &str) -> String { + match wit_type { + // Integer types - all become number in TypeScript + "s8" | "u8" | "s16" | "u16" | "s32" | "u32" | "s64" | "u64" => "number".to_string(), + // Floating point types + "f32" | "f64" => "number".to_string(), + // Other primitive types + "string" => "string".to_string(), + "bool" => "boolean".to_string(), + "_" => "void".to_string(), + // Special types + "address" => "string".to_string(), // Address would be a string in TypeScript + // Collection types with generics + t if t.starts_with("list<") => { + let inner_type = &t[5..t.len() - 1]; + // Special case for list which becomes number[] + if inner_type == "u8" { + "number[]".to_string() + } else { + format!("{}[]", wit_type_to_typescript(inner_type)) + } + } + t if t.starts_with("option<") => { + let inner_type = &t[7..t.len() - 1]; + format!("{} | null", wit_type_to_typescript(inner_type)) + } + t if t.starts_with("result<") => { + let inner_part = &t[7..t.len() - 1]; + if let Some(comma_pos) = inner_part.find(',') { + let ok_type = &inner_part[..comma_pos].trim(); + let err_type = &inner_part[comma_pos + 1..].trim(); + format!( + "{{ Ok: {} }} | {{ Err: {} }}", + wit_type_to_typescript(ok_type), + wit_type_to_typescript(err_type) + ) + } else { + format!( + "{{ Ok: {} }} | {{ Err: void }}", + wit_type_to_typescript(inner_part) + ) + } + } + t if t.starts_with("tuple<") => { + let inner_types = &t[6..t.len() - 1]; + let ts_types: Vec = inner_types + .split(", ") + .map(|t| wit_type_to_typescript(t)) + .collect(); + format!("[{}]", ts_types.join(", ")) + } + // Custom types (in kebab-case) need to be converted to PascalCase + _ => to_pascal_case(wit_type).to_string(), + } +} + +// Extract the inner type from a Result type for function returns +fn extract_result_ok_type(wit_type: &str) -> Option { + if wit_type.starts_with("result<") { + let inner_part = &wit_type[7..wit_type.len() - 1]; + if let Some(comma_pos) = inner_part.find(',') { + let ok_type = inner_part[..comma_pos].trim(); + Some(wit_type_to_typescript(ok_type)) + } else { + // Result with no error type + Some(wit_type_to_typescript(inner_part)) + } + } else { + None + } +} + +// Structure to represent a field in a WIT signature struct +#[derive(Debug)] +struct SignatureField { + name: String, + wit_type: String, +} + +// Structure to represent a WIT signature struct +#[derive(Debug)] +struct SignatureStruct { + function_name: String, + attr_type: String, + fields: Vec, +} + +// Structure to represent a WIT record +#[derive(Debug)] +struct WitRecord { + name: String, + fields: Vec, +} + +// Structure to represent a WIT variant +#[derive(Debug)] +struct WitVariant { + name: String, + cases: Vec, +} + +// Structure to hold all parsed WIT types +struct WitTypes { + signatures: Vec, + records: Vec, + variants: Vec, +} + +// Parse WIT file to extract function signatures, records, and variants +#[instrument(level = "trace", skip_all)] +fn parse_wit_file(file_path: &Path) -> Result { + debug!(file = %file_path.display(), "Parsing WIT file"); + + let content = fs::read_to_string(file_path) + .with_context(|| format!("Failed to read WIT file: {}", file_path.display()))?; + + let mut signatures = Vec::new(); + let mut records = Vec::new(); + let mut variants = Vec::new(); + + // Simple parser for WIT files to extract record definitions + let lines: Vec<_> = content.lines().collect(); + let mut i = 0; + + while i < lines.len() { + let line = lines[i].trim(); + + // Look for record definitions + if line.starts_with("record ") { + let record_name = line + .trim_start_matches("record ") + .trim_end_matches(" {") + .trim(); + + if record_name.contains("-signature-") { + // This is a signature record + debug!(name = %record_name, "Found signature record"); + + // Extract function name and attribute type + let parts: Vec<_> = record_name.split("-signature-").collect(); + if parts.len() != 2 { + warn!(name = %record_name, "Unexpected signature record name format, skipping"); + i += 1; + continue; + } + + let function_name = parts[0].to_string(); + let attr_type = parts[1].to_string(); + debug!(function = %function_name, attr_type = %attr_type, "Extracted function name and type"); + + // Parse fields + let mut fields = Vec::new(); + i += 1; + + while i < lines.len() && !lines[i].trim().starts_with("}") { + let field_line = lines[i].trim(); + + // Skip comments and empty lines + if field_line.starts_with("//") || field_line.is_empty() { + i += 1; + continue; + } + + // Parse field definition + let field_parts: Vec<_> = field_line.split(':').collect(); + if field_parts.len() == 2 { + let field_name = field_parts[0].trim().to_string(); + let field_type = field_parts[1].trim().trim_end_matches(',').to_string(); + + debug!(name = %field_name, wit_type = %field_type, "Found field"); + fields.push(SignatureField { + name: field_name, + wit_type: field_type, + }); + } + + i += 1; + } + + signatures.push(SignatureStruct { + function_name, + attr_type, + fields, + }); + } else { + // This is a regular record + debug!(name = %record_name, "Found record"); + + // Parse fields + let mut fields = Vec::new(); + i += 1; + + while i < lines.len() && !lines[i].trim().starts_with("}") { + let field_line = lines[i].trim(); + + // Skip comments and empty lines + if field_line.starts_with("//") || field_line.is_empty() { + i += 1; + continue; + } + + // Parse field definition + let field_parts: Vec<_> = field_line.split(':').collect(); + if field_parts.len() == 2 { + let field_name = field_parts[0].trim().to_string(); + let field_type = field_parts[1].trim().trim_end_matches(',').to_string(); + + debug!(name = %field_name, wit_type = %field_type, "Found field"); + fields.push(SignatureField { + name: field_name, + wit_type: field_type, + }); + } + + i += 1; + } + + records.push(WitRecord { + name: record_name.to_string(), + fields, + }); + } + } + // Look for variant definitions + else if line.starts_with("variant ") { + let variant_name = line + .trim_start_matches("variant ") + .trim_end_matches(" {") + .trim(); + debug!(name = %variant_name, "Found variant"); + + // Parse cases + let mut cases = Vec::new(); + i += 1; + + while i < lines.len() && !lines[i].trim().starts_with("}") { + let case_line = lines[i].trim(); + + // Skip comments and empty lines + if case_line.starts_with("//") || case_line.is_empty() { + i += 1; + continue; + } + + // Parse case - just the name, ignoring any associated data for now + let case_name = case_line.trim_end_matches(',').to_string(); + debug!(case = %case_name, "Found variant case"); + cases.push(case_name); + + i += 1; + } + + variants.push(WitVariant { + name: variant_name.to_string(), + cases, + }); + } + + i += 1; + } + + debug!( + file = %file_path.display(), + signatures = signatures.len(), + records = records.len(), + variants = variants.len(), + "Finished parsing WIT file" + ); + Ok(WitTypes { + signatures, + records, + variants, + }) +} + +// Generate TypeScript interface from a WIT record +fn generate_typescript_interface(record: &WitRecord) -> String { + let interface_name = to_pascal_case(&record.name); + let mut fields = Vec::new(); + + for field in &record.fields { + let field_name = to_camel_case(&field.name); + let ts_type = wit_type_to_typescript(&field.wit_type); + fields.push(format!(" {}: {};", field_name, ts_type)); + } + + format!( + "export interface {} {{\n{}\n}}", + interface_name, + fields.join("\n") + ) +} + +// Generate TypeScript type from a WIT variant +fn generate_typescript_variant(variant: &WitVariant) -> String { + let type_name = to_pascal_case(&variant.name); + let cases: Vec = variant + .cases + .iter() + .map(|case| format!("\"{}\"", to_pascal_case(case))) + .collect(); + + format!("export type {} = {};", type_name, cases.join(" | ")) +} + +// Generate TypeScript interface and function from a signature struct +fn generate_typescript_function(signature: &SignatureStruct) -> (String, String, String) { + // Convert function name from kebab-case to camelCase + let camel_function_name = to_camel_case(&signature.function_name); + let pascal_function_name = to_pascal_case(&signature.function_name); + + debug!(name = %camel_function_name, "Generating TypeScript function"); + + // Extract parameters and return type + let mut params = Vec::new(); + let mut param_names = Vec::new(); + let mut param_types = Vec::new(); + let mut full_return_type = "void".to_string(); + let mut unwrapped_return_type = "void".to_string(); + + for field in &signature.fields { + let field_name_camel = to_camel_case(&field.name); + let ts_type = wit_type_to_typescript(&field.wit_type); + debug!(field = %field.name, wit_type = %field.wit_type, ts_type = %ts_type, "Processing field"); + + if field.name == "target" { + // Skip target field as it's handled internally + continue; + } else if field.name == "returning" { + full_return_type = ts_type.clone(); + // Check if it's a Result type and extract the Ok type + if let Some(ok_type) = extract_result_ok_type(&field.wit_type) { + unwrapped_return_type = ok_type; + } else { + unwrapped_return_type = ts_type; + } + debug!(return_type = %unwrapped_return_type, "Identified return type"); + } else { + params.push(format!("{}: {}", field_name_camel, ts_type)); + param_names.push(field_name_camel); + param_types.push(ts_type); + } + } + + // Generate request interface + let request_interface = if param_names.is_empty() { + // No parameters case + format!( + "export interface {}Request {{\n {}: null\n}}", + pascal_function_name, pascal_function_name + ) + } else if param_names.len() == 1 { + // Single parameter case + format!( + "export interface {}Request {{\n {}: {}\n}}", + pascal_function_name, pascal_function_name, param_types[0] + ) + } else { + // Multiple parameters case - use tuple format + format!( + "export interface {}Request {{\n {}: [{}]\n}}", + pascal_function_name, + pascal_function_name, + param_types.join(", ") + ) + }; + + // Generate response type alias (using the full Result type) + let response_type = format!( + "export type {}Response = {};", + pascal_function_name, full_return_type + ); + + // Generate function implementation + let function_params = params.join(", "); + + let data_construction = if param_names.is_empty() { + format!( + " const data: {}Request = {{\n {}: null,\n }};", + pascal_function_name, pascal_function_name + ) + } else if param_names.len() == 1 { + format!( + " const data: {}Request = {{\n {}: {},\n }};", + pascal_function_name, pascal_function_name, param_names[0] + ) + } else { + format!( + " const data: {}Request = {{\n {}: [{}],\n }};", + pascal_function_name, + pascal_function_name, + param_names.join(", ") + ) + }; + + // Function returns the unwrapped type since parseResultResponse extracts it + let function_impl = format!( + "/**\n * {}\n{} * @returns Promise with result\n * @throws ApiError if the request fails\n */\nexport async function {}({}): Promise<{}> {{\n{}\n\n return await apiRequest<{}Request, {}>('{}', 'POST', data);\n}}", + camel_function_name, + params.iter().map(|p| format!(" * @param {}", p)).collect::>().join("\n"), + camel_function_name, + function_params, + unwrapped_return_type, // Use unwrapped type as the function return + data_construction, + pascal_function_name, + unwrapped_return_type, // Pass unwrapped type to apiRequest, not Response type + camel_function_name + ); + + // Only return implementations for HTTP endpoints + if signature.attr_type == "http" { + (request_interface, response_type, function_impl) + } else { + debug!("Skipping non-HTTP endpoint"); + (String::new(), String::new(), String::new()) + } +} + +// Public entry point for creating TypeScript caller-utils +#[instrument(level = "trace", skip_all)] +pub fn create_typescript_caller_utils(base_dir: &Path, api_dir: &Path) -> Result<()> { + // Path to the new TypeScript file + let ui_target_dir = base_dir.join("target").join("ui"); + let caller_utils_path = ui_target_dir.join("caller-utils.ts"); + + debug!( + api_dir = %api_dir.display(), + call_utils_path = %caller_utils_path.display(), + "Creating TypeScript caller-utils" + ); + + // Find all WIT files in the api directory + let mut wit_files = Vec::new(); + for entry in WalkDir::new(api_dir) + .max_depth(1) + .into_iter() + .filter_map(Result::ok) + { + let path = entry.path(); + if path.is_file() && path.extension().map_or(false, |ext| ext == "wit") { + // Exclude world definition files + if let Ok(content) = fs::read_to_string(path) { + if !content.contains("world ") { + debug!(file = %path.display(), "Adding WIT file for parsing"); + wit_files.push(path.to_path_buf()); + } else { + debug!(file = %path.display(), "Skipping world definition WIT file"); + } + } + } + } + + debug!( + count = wit_files.len(), + "Found WIT interface files for TypeScript generation" + ); + + // Generate TypeScript content + let mut ts_content = String::new(); + + // Add the header with common utilities (always present) + ts_content.push_str("// Define a custom error type for API errors\n"); + ts_content.push_str("export class ApiError extends Error {\n"); + ts_content.push_str(" constructor(message: string, public readonly details?: unknown) {\n"); + ts_content.push_str(" super(message);\n"); + ts_content.push_str(" this.name = 'ApiError';\n"); + ts_content.push_str(" }\n"); + ts_content.push_str("}\n\n"); + + ts_content.push_str("// Parser for the Result-style responses\n"); + ts_content.push_str("// eslint-disable-next-line @typescript-eslint/no-explicit-any\n"); + ts_content.push_str("export function parseResultResponse(response: any): T {\n"); + ts_content.push_str( + " if ('Ok' in response && response.Ok !== undefined && response.Ok !== null) {\n", + ); + ts_content.push_str(" return response.Ok as T;\n"); + ts_content.push_str(" }\n\n"); + ts_content.push_str(" if ('Err' in response && response.Err !== undefined) {\n"); + ts_content.push_str(" throw new ApiError(`API returned an error`, response.Err);\n"); + ts_content.push_str(" }\n\n"); + ts_content.push_str(" throw new ApiError('Invalid API response format');\n"); + ts_content.push_str("}\n\n"); + + ts_content.push_str("/**\n"); + ts_content.push_str(" * Generic API request function\n"); + ts_content.push_str(" * @param endpoint - API endpoint\n"); + ts_content.push_str(" * @param method - HTTP method (GET, POST, PUT, DELETE, etc.)\n"); + ts_content.push_str(" * @param data - Request data\n"); + ts_content.push_str(" * @returns Promise with parsed response data\n"); + ts_content.push_str(" * @throws ApiError if the request fails or response contains an error\n"); + ts_content.push_str(" */\n"); + ts_content.push_str("async function apiRequest(endpoint: string, method: string, data: T): Promise {\n"); + ts_content + .push_str(" const BASE_URL = import.meta.env.BASE_URL || window.location.origin;\n\n"); + ts_content.push_str(" const requestOptions: RequestInit = {\n"); + ts_content.push_str(" method: method,\n"); + ts_content.push_str(" headers: {\n"); + ts_content.push_str(" \"Content-Type\": \"application/json\",\n"); + ts_content.push_str(" },\n"); + ts_content.push_str(" };\n\n"); + ts_content.push_str(" // Only add body for methods that support it\n"); + ts_content.push_str(" if (method !== 'GET' && method !== 'HEAD') {\n"); + ts_content.push_str(" requestOptions.body = JSON.stringify(data);\n"); + ts_content.push_str(" }\n\n"); + ts_content.push_str(" const result = await fetch(`${BASE_URL}/api`, requestOptions);\n\n"); + ts_content.push_str(" if (!result.ok) {\n"); + ts_content + .push_str(" throw new ApiError(`HTTP request failed with status: ${result.status}`);\n"); + ts_content.push_str(" }\n\n"); + ts_content.push_str(" const jsonResponse = await result.json();\n"); + ts_content.push_str(" return parseResultResponse(jsonResponse);\n"); + ts_content.push_str("}\n\n"); + + // Collect all interfaces, types, and functions + let mut all_interfaces = Vec::new(); + let mut all_types = Vec::new(); + let mut all_functions = Vec::new(); + let mut function_names = Vec::new(); + let mut custom_types = Vec::new(); // For records and variants + + // Generate content for each WIT file + for wit_file in &wit_files { + match parse_wit_file(wit_file) { + Ok(wit_types) => { + // Process custom types (records and variants) + for record in &wit_types.records { + let interface_def = generate_typescript_interface(record); + custom_types.push(interface_def); + } + + for variant in &wit_types.variants { + let type_def = generate_typescript_variant(variant); + custom_types.push(type_def); + } + + // Process function signatures + for signature in &wit_types.signatures { + let (interface_def, type_def, function_def) = + generate_typescript_function(&signature); + + if !interface_def.is_empty() { + all_interfaces.push(interface_def); + all_types.push(type_def); + all_functions.push(function_def); + function_names.push(to_camel_case(&signature.function_name)); + } + } + } + Err(e) => { + warn!(file = %wit_file.display(), error = %e, "Error parsing WIT file, skipping"); + } + } + } + + // If no HTTP functions were found, don't generate the file + if all_functions.is_empty() { + debug!("No HTTP functions found in WIT files, skipping TypeScript generation"); + return Ok(()); + } + + // Create directories only after we know we have HTTP functions + fs::create_dir_all(&ui_target_dir)?; + debug!("Created UI target directory structure"); + + // Add custom types (records and variants) first + if !custom_types.is_empty() { + ts_content.push_str("\n// Custom Types from WIT definitions\n\n"); + ts_content.push_str(&custom_types.join("\n\n")); + ts_content.push_str("\n\n"); + } + + // Add all collected definitions + if !all_interfaces.is_empty() { + ts_content.push_str("\n// API Interface Definitions\n\n"); + ts_content.push_str(&all_interfaces.join("\n\n")); + ts_content.push_str("\n\n"); + ts_content.push_str(&all_types.join("\n\n")); + ts_content.push_str("\n\n"); + } + + if !all_functions.is_empty() { + ts_content.push_str("// API Function Implementations\n\n"); + ts_content.push_str(&all_functions.join("\n\n")); + ts_content.push_str("\n\n"); + } + + // No need for explicit exports since functions are already exported inline + + // Write the TypeScript file + debug!( + "Writing generated TypeScript code to {}", + caller_utils_path.display() + ); + fs::write(&caller_utils_path, ts_content).with_context(|| { + format!( + "Failed to write caller-utils.ts: {}", + caller_utils_path.display() + ) + })?; + + info!( + "Successfully created TypeScript caller-utils at {}", + caller_utils_path.display() + ); + Ok(()) +} diff --git a/src/build/mod.rs b/src/build/mod.rs index 7f269831..6646ef11 100644 --- a/src/build/mod.rs +++ b/src/build/mod.rs @@ -32,6 +32,10 @@ use crate::KIT_CACHE; mod rewrite; use rewrite::copy_and_rewrite_package; +mod caller_utils_generator; +mod caller_utils_ts_generator; +mod wit_generator; + const PY_VENV_NAME: &str = "process_env"; const JAVASCRIPT_SRC_PATH: &str = "src/lib.js"; const PYTHON_SRC_PATH: &str = "src/lib.py"; @@ -166,6 +170,25 @@ pub fn remove_missing_features(cargo_toml_path: &Path, features: Vec<&str>) -> R .collect()) } +#[instrument(level = "trace", skip_all)] +pub fn get_process_name(cargo_toml_path: &Path) -> Result { + let cargo_toml_content = fs::read_to_string(cargo_toml_path)?; + let cargo_toml: toml::Value = cargo_toml_content.parse()?; + + if let Some(process_name) = cargo_toml + .get("package") + .and_then(|p| p.get("name")) + .and_then(|n| n.as_str()) + { + let process_name = process_name.replace("_", "-"); + Ok(process_name.to_string()) + } else { + Err(eyre!( + "No package.name field in Cargo.toml at {cargo_toml_path:?}" + )) + } +} + /// Check if the first element is empty and there are no more elements #[instrument(level = "trace", skip_all)] fn is_only_empty_string(splitted: &Vec<&str>) -> bool { @@ -649,8 +672,6 @@ fn get_most_recent_modified_time( return Err(eyre!("Didn't find required dirs: {must_exist_dirs:?}")); } - debug!("get_most_recent_modified_time: most_recent: {most_recent:?}, most_recent_excluded: {most_recent_excluded:?}"); - Ok((most_recent, most_recent_excluded)) } @@ -898,18 +919,24 @@ async fn compile_rust_wasm_process( features: &str, verbose: bool, ) -> Result<()> { + let Some(package_dir) = process_dir.parent() else { + return Err(eyre!( + "Could not derive package dir from process_dir ({process_dir:?}) parent" + )); + }; + let process_name = get_process_name(&process_dir.join("Cargo.toml"))?; info!("Compiling Rust Hyperware process in {:?}...", process_dir); // Paths - let wit_dir = process_dir.join("target").join("wit"); - let bindings_dir = process_dir + let wit_dir = package_dir.join("target").join("wit"); + let bindings_dir = package_dir .join("target") .join("bindings") - .join(process_dir.file_name().unwrap()); + .join(package_dir.file_name().unwrap()); fs::create_dir_all(&bindings_dir)?; // Check and download wasi_snapshot_preview1.wasm if it does not exist - let wasi_snapshot_file = process_dir + let wasi_snapshot_file = package_dir .join("target") .join("wasi_snapshot_preview1.wasm"); let wasi_snapshot_url = format!( @@ -932,6 +959,8 @@ async fn compile_rust_wasm_process( let mut args = vec![ "+stable", "build", + "-p", + &process_name, "--release", "--no-default-features", "--target", @@ -947,7 +976,7 @@ async fn compile_rust_wasm_process( } else { features.len() }; - let features = remove_missing_features(&process_dir.join("Cargo.toml"), features)?; + let features = remove_missing_features(&package_dir.join("Cargo.toml"), features)?; if !test_only && original_length != features.len() { info!( "process {:?} missing features; using {:?}", @@ -960,7 +989,7 @@ async fn compile_rust_wasm_process( args.push(&features); } let result = run_command( - Command::new("cargo").args(&args).current_dir(process_dir), + Command::new("cargo").args(&args).current_dir(package_dir), verbose, )?; @@ -978,7 +1007,7 @@ async fn compile_rust_wasm_process( // For use inside of process_dir // Run `wasm-tools component new`, putting output in pkg/ // and rewriting all `_`s to `-`s - // cargo hates `-`s and so outputs with `_`s; Kimap hates + // cargo hates `-`s and so outputs with `_`s; Hypermap hates // `_`s and so we convert to and enforce all `-`s let wasm_file_name_cab = process_dir .file_name() @@ -990,7 +1019,7 @@ async fn compile_rust_wasm_process( let wasm_file_prefix = Path::new("target/wasm32-wasip1/release"); let wasm_file_cab = wasm_file_prefix.join(&format!("{wasm_file_name_cab}.wasm")); - let wasm_file_pkg = format!("../pkg/{wasm_file_name_hep}.wasm"); + let wasm_file_pkg = format!("pkg/{wasm_file_name_hep}.wasm"); let wasm_file_pkg = Path::new(&wasm_file_pkg); let wasi_snapshot_file = Path::new("target/wasi_snapshot_preview1.wasm"); @@ -1006,7 +1035,7 @@ async fn compile_rust_wasm_process( "--adapt", wasi_snapshot_file.to_str().unwrap(), ]) - .current_dir(process_dir), + .current_dir(package_dir), verbose, )?; @@ -1067,11 +1096,11 @@ async fn compile_and_copy_ui( #[instrument(level = "trace", skip_all)] async fn build_wit_dir( - process_dir: &Path, + package_dir: &Path, apis: &HashMap>, wit_version: Option, ) -> Result<()> { - let wit_dir = process_dir.join("target").join("wit"); + let wit_dir = package_dir.join("target").join("wit"); if wit_dir.exists() { fs::remove_dir_all(&wit_dir)?; } @@ -1090,29 +1119,21 @@ async fn build_wit_dir( async fn compile_package_item( path: PathBuf, features: String, - apis: HashMap>, world: String, - wit_version: Option, + is_rust_process: bool, + is_py_process: bool, + is_js_process: bool, verbose: bool, ) -> Result<()> { - if path.is_dir() { - let is_rust_process = path.join(RUST_SRC_PATH).exists(); - let is_py_process = path.join(PYTHON_SRC_PATH).exists(); - let is_js_process = path.join(JAVASCRIPT_SRC_PATH).exists(); - if is_rust_process || is_py_process || is_js_process { - build_wit_dir(&path, &apis, wit_version).await?; - } - - if is_rust_process { - compile_rust_wasm_process(&path, &features, verbose).await?; - } else if is_py_process { - let python = get_python_version(None, None)? - .ok_or_else(|| eyre!("kit requires Python 3.10 or newer"))?; - compile_python_wasm_process(&path, &python, &world, verbose).await?; - } else if is_js_process { - let valid_node = get_newest_valid_node_version(None, None)?; - compile_javascript_wasm_process(&path, valid_node, &world, verbose).await?; - } + if is_rust_process { + compile_rust_wasm_process(&path, &features, verbose).await?; + } else if is_py_process { + let python = get_python_version(None, None)? + .ok_or_else(|| eyre!("kit requires Python 3.10 or newer"))?; + compile_python_wasm_process(&path, &python, &world, verbose).await?; + } else if is_js_process { + let valid_node = get_newest_valid_node_version(None, None)?; + compile_javascript_wasm_process(&path, valid_node, &world, verbose).await?; } Ok(()) } @@ -1161,6 +1182,7 @@ async fn fetch_dependencies( include: &HashSet, exclude: &HashSet, rewrite: bool, + hyperapp: bool, force: bool, verbose: bool, ) -> Result<()> { @@ -1178,6 +1200,7 @@ async fn fetch_dependencies( vec![], // TODO: what about deps-of-deps? vec![], rewrite, + hyperapp, false, force, verbose, @@ -1215,6 +1238,7 @@ async fn fetch_dependencies( local_dep_deps, vec![], rewrite, + hyperapp, false, force, verbose, @@ -1531,8 +1555,10 @@ async fn compile_package( include: &HashSet, exclude: &HashSet, rewrite: bool, + hyperapp: bool, force: bool, verbose: bool, + hyperapp_processed_projects: Option>, ignore_deps: bool, // for internal use; may cause problems when adding recursive deps ) -> Result<()> { let metadata = read_and_update_metadata(package_dir)?; @@ -1540,7 +1566,9 @@ async fn compile_package( let (mut apis, dependencies) = check_and_populate_dependencies(package_dir, &metadata, skip_deps_check, verbose).await?; + info!("dependencies: {dependencies:?}"); if !ignore_deps && !dependencies.is_empty() { + info!("fetching dependencies..."); fetch_dependencies( package_dir, &dependencies.iter().map(|s| s.to_string()).collect(), @@ -1554,10 +1582,11 @@ async fn compile_package( include, exclude, rewrite, + hyperapp, force, verbose, ) - .await?; + .await? } let wit_world = default_world @@ -1566,8 +1595,11 @@ async fn compile_package( }) .to_string(); + build_wit_dir(&package_dir, &apis, metadata.properties.wit_version).await?; + let mut tasks = tokio::task::JoinSet::new(); let features = features.to_string(); + let mut to_compile = HashSet::new(); for entry in fs::read_dir(package_dir)? { let Ok(entry) = entry else { continue; @@ -1576,12 +1608,35 @@ async fn compile_package( if !is_cluded(&path, include, exclude) { continue; } + if !path.is_dir() { + continue; + } + + let is_rust_process = path.join(RUST_SRC_PATH).exists(); + let is_py_process = path.join(PYTHON_SRC_PATH).exists(); + let is_js_process = path.join(JAVASCRIPT_SRC_PATH).exists(); + if is_rust_process || is_py_process || is_js_process { + to_compile.insert((path, is_rust_process, is_py_process, is_js_process)); + } + } + + let api_dir = package_dir.join("target").join("wit"); + //info!("{processed_project:?} {api_dir:?}"); + if let Some(ref processed_projects) = hyperapp_processed_projects { + caller_utils_generator::create_caller_utils(package_dir, &api_dir)?; + for processed_project in processed_projects { + caller_utils_generator::add_caller_utils_to_projects(&[processed_project.clone()])?; + } + } + + for (path, is_rust_process, is_py_process, is_js_process) in to_compile { tasks.spawn(compile_package_item( path, features.clone(), - apis.clone(), wit_world.clone(), - metadata.properties.wit_version, + is_rust_process, + is_py_process, + is_js_process, verbose.clone(), )); } @@ -1661,6 +1716,7 @@ pub async fn execute( local_dependencies: Vec, add_paths_to_api: Vec, rewrite: bool, + hyperapp: bool, reproducible: bool, force: bool, verbose: bool, @@ -1753,6 +1809,23 @@ pub async fn execute( copy_and_rewrite_package(package_dir)? }; + let hyperapp_processed_projects = if !hyperapp { + None + } else { + let api_dir = live_dir.join("api"); + let (processed_projects, interfaces) = + wit_generator::generate_wit_files(&live_dir, &api_dir)?; + + // generate ts bindings before building ui + caller_utils_ts_generator::create_typescript_caller_utils(&live_dir, &api_dir)?; + + if interfaces.is_empty() { + None + } else { + Some(processed_projects) + } + }; + let ui_dirs = get_ui_dirs(&live_dir, &include, &exclude)?; if !no_ui && !ui_dirs.is_empty() { if !skip_deps_check { @@ -1779,8 +1852,10 @@ pub async fn execute( &include, &exclude, rewrite, + hyperapp, force, verbose, + hyperapp_processed_projects, ignore_deps, ) .await?; diff --git a/src/build/wit_generator.rs b/src/build/wit_generator.rs new file mode 100644 index 00000000..15ead1b4 --- /dev/null +++ b/src/build/wit_generator.rs @@ -0,0 +1,1382 @@ +use std::collections::{HashMap, HashSet}; +use std::fs; +use std::path::{Path, PathBuf}; + +use color_eyre::{ + eyre::{bail, eyre, WrapErr}, + Result, +}; +use syn::{self, Attribute, ImplItem, Item, Type}; +use toml::Value; +use tracing::{debug, info, instrument, warn}; +use walkdir::WalkDir; + +// Helper functions for naming conventions +fn to_kebab_case(s: &str) -> String { + // First, handle the case where the input has underscores + if s.contains('_') { + return s.replace('_', "-"); + } + + let mut result = String::with_capacity(s.len() + 5); // Extra capacity for hyphens + let chars: Vec = s.chars().collect(); + + for (i, &c) in chars.iter().enumerate() { + if c.is_uppercase() { + // Add hyphen if: + // 1. Not the first character + // 2. Previous character is lowercase + // 3. Or next character is lowercase (to handle acronyms like HTML) + if i > 0 + && (chars[i - 1].is_lowercase() + || (i < chars.len() - 1 && chars[i + 1].is_lowercase())) + { + result.push('-'); + } + result.push(c.to_lowercase().next().unwrap()); + } else { + result.push(c); + } + } + + result +} + +// Validates a name doesn't contain numbers or "stream" +fn validate_name(name: &str, kind: &str) -> Result<()> { + // Check for numbers + if name.chars().any(|c| c.is_digit(10)) { + bail!( + "Error: {} name '{}' contains numbers, which is not allowed", + kind, + name + ); + } + + // Check for "stream" + if name.to_lowercase().contains("stream") { + bail!( + "Error: {} name '{}' contains 'stream', which is not allowed", + kind, + name + ); + } + + Ok(()) +} + +// Check if a field name starts with an underscore, and if so, strip it and print a warning. +fn check_and_strip_leading_underscore(field_name: String) -> String { + if let Some(stripped) = field_name.strip_prefix('_') { + warn!(field_name = %field_name, + " Warning: Field name starts with an underscore ('_'), which is invalid in WIT. Stripping the underscore from WIT definition. Function signatures should only include parameters that are actually used." + ); + stripped.to_string() + } else { + field_name + } +} + +// Remove "State" suffix from a name +fn remove_state_suffix(name: &str) -> String { + if name.ends_with("State") { + let len = name.len(); + return name[0..len - 5].to_string(); + } + name.to_string() +} + +// Extract wit_world from the #[hyperprocess] attribute using the format in the debug representation +#[instrument(level = "trace", skip_all)] +fn extract_wit_world(attrs: &[Attribute]) -> Result { + for attr in attrs { + if attr.path().is_ident("hyperprocess") { + // Convert attribute to string representation + let attr_str = format!("{:?}", attr); + debug!(attr_str = %attr_str, "Attribute string"); + + // Look for wit_world in the attribute string + if let Some(pos) = attr_str.find("wit_world") { + debug!(pos = %pos, "Found wit_world"); + + // Find the literal value after wit_world by looking for lit: "value" + let lit_pattern = "lit: \""; + if let Some(lit_pos) = attr_str[pos..].find(lit_pattern) { + let start_pos = pos + lit_pos + lit_pattern.len(); + + // Find the closing quote of the literal + if let Some(quote_pos) = attr_str[start_pos..].find('\"') { + let world_name = &attr_str[start_pos..(start_pos + quote_pos)]; + debug!(wit_world = %world_name, "Extracted wit_world"); + return Ok(world_name.to_string()); + } + } + } + } + } + bail!("wit_world not found in hyperprocess attribute") +} +// Helper function to check if a WIT type name is a primitive or known built-in +fn is_wit_primitive_or_builtin(type_name: &str) -> bool { + matches!( + type_name, + "s8" | "u8" + | "s16" + | "u16" + | "s32" + | "u32" + | "s64" + | "u64" + | "f32" + | "f64" + | "bool" + | "char" + | "string" + | "address" + ) || type_name.starts_with("list<") + || type_name.starts_with("option<") + || type_name.starts_with("result<") + || type_name.starts_with("tuple<") +} + +// Extract custom type names from a WIT type string (e.g., "list" -> ["foo-bar"]) +fn extract_custom_types_from_wit_type(wit_type: &str) -> Vec { + let mut custom_types = Vec::new(); + + // Skip if it's a primitive type + if is_wit_primitive_or_builtin(wit_type) && !wit_type.contains('<') { + return custom_types; + } + + // Handle composite types like list, option, result, tuple + if let Some(start) = wit_type.find('<') { + if let Some(end) = wit_type.rfind('>') { + let inner = &wit_type[start + 1..end]; + + // Split by comma to handle multiple type parameters + for part in inner.split(',') { + let trimmed = part.trim(); + if !trimmed.is_empty() && trimmed != "_" && !is_wit_primitive_or_builtin(trimmed) { + // Recursively extract from nested types + if trimmed.contains('<') { + custom_types.extend(extract_custom_types_from_wit_type(trimmed)); + } else { + custom_types.push(trimmed.to_string()); + } + } + } + } + } else if !is_wit_primitive_or_builtin(wit_type) { + // It's a non-composite custom type + custom_types.push(wit_type.to_string()); + } + + custom_types +} + +// Convert Rust type to WIT type, including downstream types +#[instrument(level = "trace", skip_all)] +fn rust_type_to_wit(ty: &Type, used_types: &mut HashSet) -> Result { + match ty { + Type::Path(type_path) => { + if type_path.path.segments.is_empty() { + return Err(eyre!("Failed to parse path type: {ty:?}")); + } + + let ident = &type_path.path.segments.last().unwrap().ident; + let type_name = ident.to_string(); + + match type_name.as_str() { + "i8" => Ok("s8".to_string()), + "u8" => Ok("u8".to_string()), + "i16" => Ok("s16".to_string()), + "u16" => Ok("u16".to_string()), + "i32" => Ok("s32".to_string()), + "u32" => Ok("u32".to_string()), + "i64" => Ok("s64".to_string()), + "u64" => Ok("u64".to_string()), + "f32" => Ok("f32".to_string()), + "f64" => Ok("f64".to_string()), + "String" => Ok("string".to_string()), + "bool" => Ok("bool".to_string()), + "Vec" => { + if let syn::PathArguments::AngleBracketed(args) = + &type_path.path.segments.last().unwrap().arguments + { + if let Some(syn::GenericArgument::Type(inner_ty)) = args.args.first() { + let inner_type = rust_type_to_wit(inner_ty, used_types)?; + Ok(format!("list<{}>", inner_type)) + } else { + Err(eyre!("Failed to parse Vec inner type")) + } + } else { + Err(eyre!("Failed to parse Vec inner type!")) + } + } + "Option" => { + if let syn::PathArguments::AngleBracketed(args) = + &type_path.path.segments.last().unwrap().arguments + { + if let Some(syn::GenericArgument::Type(inner_ty)) = args.args.first() { + let inner_type = rust_type_to_wit(inner_ty, used_types)?; + Ok(format!("option<{}>", inner_type)) + } else { + Err(eyre!("Failed to parse Option inner type")) + } + } else { + Err(eyre!("Failed to parse Option inner type!")) + } + } + "Result" => { + if let syn::PathArguments::AngleBracketed(args) = + &type_path.path.segments.last().unwrap().arguments + { + // Strictly enforce exactly two arguments for Result + if args.args.len() == 2 { + if let ( + Some(syn::GenericArgument::Type(ok_ty)), + Some(syn::GenericArgument::Type(err_ty)), + ) = (args.args.first(), args.args.get(1)) + { + let ok_type_str = rust_type_to_wit(ok_ty, used_types)?; + let err_type_str = rust_type_to_wit(err_ty, used_types)?; + + // Map Rust's () (represented as "_") to WIT's _ in result<...> + let final_ok = if ok_type_str == "_" { + // Check for "_" + "_" + } else { + &ok_type_str + }; + let final_err = if err_type_str == "_" { + // Check for "_" + "_" + } else { + &err_type_str + }; + + // Format the WIT result string according to WIT conventions + let result_string = match (final_ok, final_err) { + ("_", "_") => "result".to_string(), // Shorthand: result + (ok, "_") => format!("result<{}>", ok), // Shorthand: result + ("_", err) => format!("result<_, {}>", err), // Explicit: result<_, E> + (ok, err) => format!("result<{}, {}>", ok, err), // Explicit: result + }; + Ok(result_string) + } else { + // This case should be unlikely if len == 2, but handle defensively + Err(eyre!("Failed to parse Result generic arguments")) + } + } else { + Err(eyre!( + "Result requires exactly two type arguments (e.g., Result), found {}", + args.args.len() + )) + } + } else { + Err(eyre!("Failed to parse Result type arguments")) + } + } + // TODO: fix and enable + //"HashMap" | "BTreeMap" => { + // if let syn::PathArguments::AngleBracketed(args) = + // &type_path.path.segments.last().unwrap().arguments + // { + // if args.args.len() >= 2 { + // if let ( + // Some(syn::GenericArgument::Type(key_ty)), + // Some(syn::GenericArgument::Type(val_ty)), + // ) = (args.args.first(), args.args.get(1)) + // { + // let key_type = rust_type_to_wit(key_ty, used_types)?; + // let val_type = rust_type_to_wit(val_ty, used_types)?; + // // For HashMaps, we'll generate a list of tuples where each tuple contains a key and value + // Ok(format!("list>", key_type, val_type)) + // } else { + // Ok("list>".to_string()) + // } + // } else { + // Ok("list>".to_string()) + // } + // } else { + // Ok("list>".to_string()) + // } + //} + custom => { + // Validate custom type name + validate_name(custom, "Type")?; + + // Convert custom type to kebab-case and add to used types + let kebab_custom = to_kebab_case(custom); + used_types.insert(kebab_custom.clone()); + Ok(kebab_custom) + } + } + } + Type::Reference(type_ref) => { + // Handle references by using the underlying type + rust_type_to_wit(&type_ref.elem, used_types) + } + // fn () -> Result<(), Error> + // tuple<> + Type::Tuple(type_tuple) => { + if type_tuple.elems.is_empty() { + // Represent () as "_" for the caller to interpret based on context. + // It's valid within Result<_, E>, but invalid as a direct return type. + Ok("_".to_string()) + } else { + // Create a tuple representation in WIT + let mut elem_types = Vec::new(); + for elem in &type_tuple.elems { + elem_types.push(rust_type_to_wit(elem, used_types)?); + } + Ok(format!("tuple<{}>", elem_types.join(", "))) + } + } + _ => return Err(eyre!("Failed to parse type: {ty:?}")), + } +} + +// Find all Rust files in a crate directory +fn find_rust_files(crate_path: &Path) -> Vec { + let mut rust_files = Vec::new(); + let src_dir = crate_path.join("src"); + + debug!(src_dir = %src_dir.display(), "Finding Rust files"); + + if !src_dir.exists() || !src_dir.is_dir() { + warn!(src_dir = %src_dir.display(), "No src directory found"); + return rust_files; + } + + for entry in WalkDir::new(src_dir).into_iter().filter_map(Result::ok) { + let path = entry.path(); + if path.is_file() && path.extension().map_or(false, |ext| ext == "rs") { + debug!(path = %path.display(), "Found Rust file"); + rust_files.push(path.to_path_buf()); + } + } + + debug!(count = %rust_files.len(), "Found Rust files"); + rust_files +} + +// Searches a single file for a specific type definition (struct or enum) by its kebab-case name. +// If found, generates its WIT definition string and returns it along with any new custom type +// dependencies discovered within its fields/variants. +#[instrument(level = "trace", skip_all)] +fn find_and_make_wit_type_def( + file_path: &Path, + target_kebab_type_name: &str, + global_used_types: &mut HashSet, // Track all used types globally +) -> Result)>> { + // Return: Ok(Some((wit_def, new_local_deps))), Ok(None), or Err + debug!( + file_path = %file_path.display(), + target_type = %target_kebab_type_name, + "Searching for type definition" + ); + + let content = fs::read_to_string(file_path) + .with_context(|| format!("Failed to read file: {}", file_path.display()))?; + + let ast = syn::parse_file(&content) + .with_context(|| format!("Failed to parse file: {}", file_path.display()))?; + + for item in &ast.items { + // Determine if the current item matches the target type name + let (is_target, item_kind, orig_name) = match item { + Item::Struct(s) => { + let name = s.ident.to_string(); + ( + to_kebab_case(&name) == target_kebab_type_name, + "Struct", + name, + ) + } + Item::Enum(e) => { + let name = e.ident.to_string(); + (to_kebab_case(&name) == target_kebab_type_name, "Enum", name) + } + _ => (false, "", String::new()), + }; + + if is_target { + // Skip internal-looking types (can be adjusted) + if orig_name.contains("__") { + warn!(name = %orig_name, "Skipping definition search for likely internal type"); + return Ok(None); // Treat as not found for WIT purposes + } + // Validate the original Rust name + validate_name(&orig_name, item_kind)?; + + let kebab_name = target_kebab_type_name; // We know this matches + let mut local_dependencies = HashSet::new(); // Track deps discovered *by this type* + + // --- Generate Struct Definition --- + if let Item::Struct(item_struct) = item { + let fields_result: Result> = match &item_struct.fields { + syn::Fields::Named(fields) => { + let mut field_strings = Vec::new(); + for f in &fields.named { + if let Some(field_ident) = &f.ident { + let field_orig_name = field_ident.to_string(); + // Validate field name (allow underscore stripping) + let stripped_field_orig_name = + check_and_strip_leading_underscore(field_orig_name.clone()); + // Validate the potentially stripped name, adding context about the rules + validate_name(&stripped_field_orig_name, "Field")?; + + let field_kebab_name = to_kebab_case(&stripped_field_orig_name); + if field_kebab_name.is_empty() { + warn!(struct_name=%kebab_name, field_original_name=%field_orig_name, "Skipping field with empty kebab-case name"); + continue; + } + + // Convert field type. `rust_type_to_wit` adds any new custom types + // found within the field type (e.g., in list) to `global_used_types`. + let field_wit_type = rust_type_to_wit(&f.ty, global_used_types) + .wrap_err_with(|| format!("Failed to convert field '{}':'{:?}' in struct '{}'", field_orig_name, f.ty, orig_name))?; + + // Extract any custom types from the field type and add them to local dependencies + // For example, from "list" we extract "participant-info" + for custom_type in extract_custom_types_from_wit_type(&field_wit_type) { + local_dependencies.insert(custom_type); + } + + field_strings.push(format!(" {}: {}", field_kebab_name, field_wit_type)); + } + } + Ok(field_strings) + } + // Handle Unit Structs as empty records + syn::Fields::Unit => Ok(Vec::new()), + // Decide how to handle Tuple Structs (e.g., error, skip, specific WIT representation?) + syn::Fields::Unnamed(_) => bail!("Tuple structs ('struct {} (...)') are not currently supported for WIT generation.", orig_name), + }; + + match fields_result { + Ok(fields_vec) => { + // Generate record definition (use {} for empty records) + let definition = if fields_vec.is_empty() { + format!(" record {} {{}}", kebab_name) + } else { + format!( + " record {} {{\n{}\n }}", + kebab_name, + fields_vec.join(",\n") + ) + }; + debug!(type_name = %kebab_name, "Generated record definition"); + return Ok(Some((definition, local_dependencies))); + } + Err(e) => return Err(e), // Propagate field processing error + } + } + + // --- Generate Enum Definition --- + if let Item::Enum(item_enum) = item { + let mut variants_wit = Vec::new(); + let mut skip_enum = false; + + for v in &item_enum.variants { + let variant_orig_name = v.ident.to_string(); + // Validate variant name before proceeding + validate_name(&variant_orig_name, "Enum variant")?; + let variant_kebab_name = to_kebab_case(&variant_orig_name); + + match &v.fields { + // Variant with one unnamed field: T -> case(T) + syn::Fields::Unnamed(fields) if fields.unnamed.len() == 1 => { + // `rust_type_to_wit` adds new custom types to `global_used_types` + let type_result = rust_type_to_wit( + &fields.unnamed.first().unwrap().ty, + global_used_types, + ) + .wrap_err_with(|| { + format!( + "Failed to convert variant '{}' type in enum '{}'", + variant_orig_name, orig_name + ) + })?; + + // Extract any custom types from the variant type and add them to local dependencies + for custom_type in extract_custom_types_from_wit_type(&type_result) { + local_dependencies.insert(custom_type); + } + variants_wit + .push(format!(" {}({})", variant_kebab_name, type_result)); + } + // Unit variant: -> case + syn::Fields::Unit => { + variants_wit.push(format!(" {}", variant_kebab_name)); + } + // Variants with named fields or multiple unnamed fields are not directly supported by WIT variants + _ => { + warn!(enum_name = %kebab_name, variant_name = %variant_orig_name, "Skipping complex enum variant (only unit variants or single-type variants like 'MyVariant(MyType)' are supported)"); + skip_enum = true; + break; // Skip the whole enum if one variant is complex + } + } + } + + // Only generate if not skipped and has convertible variants + if !skip_enum && !variants_wit.is_empty() { + let definition = format!( + " variant {} {{\n{}\n }}", + kebab_name, + variants_wit.join(",\n") + ); + debug!(type_name = %kebab_name, "Generated variant definition"); + return Ok(Some((definition, local_dependencies))); + } else { + // Treat as not found for WIT generation if skipped or empty + warn!(name = %kebab_name, "Skipping enum definition due to complex/invalid variants or no convertible variants"); + return Ok(None); + } + } + // Should not be reached if item is Struct or Enum and is_target is true + unreachable!("Target type matched but was neither struct nor enum?"); + } + } + + // Target type definition was not found in this specific file + Ok(None) +} + +// Find all relevant Rust projects +fn find_rust_projects(base_dir: &Path) -> Vec { + let mut projects = Vec::new(); + debug!(base_dir = %base_dir.display(), "Scanning for Rust projects"); + + for entry in WalkDir::new(base_dir) + .max_depth(1) + .into_iter() + .filter_map(Result::ok) + { + let path = entry.path(); + + if !path.is_dir() || path == base_dir { + continue; + } + let cargo_toml = path.join("Cargo.toml"); + debug!(path = %cargo_toml.display(), "Checking path"); + + if !cargo_toml.exists() { + continue; + } + // Try to read and parse Cargo.toml + let Ok(content) = fs::read_to_string(&cargo_toml) else { + continue; + }; + let Ok(cargo_data) = content.parse::() else { + continue; + }; + // Check for the specific metadata + let Some(metadata) = cargo_data + .get("package") + .and_then(|p| p.get("metadata")) + .and_then(|m| m.get("component")) + else { + warn!(path = %cargo_toml.display(), "No package.metadata.component metadata found"); + continue; + }; + let Some(package) = metadata.get("package") else { + continue; + }; + let Some(package_str) = package.as_str() else { + continue; + }; + debug!(package = %package_str, "Found package.metadata.component.package"); + if package_str == "hyperware:process" { + debug!(path = %path.display(), "Adding project"); + projects.push(path.to_path_buf()); + } + } + + debug!(count = %projects.len(), "Found relevant Rust projects"); + projects +} + +// Helper function to generate signature struct for specific attribute type +#[instrument(level = "trace", skip_all)] +fn generate_signature_struct( + kebab_name: &str, + attr_type: &str, + method: &syn::ImplItemFn, + used_types: &mut HashSet, +) -> Result { + // Create signature struct name with attribute type + let signature_struct_name = format!("{}-signature-{}", kebab_name, attr_type); + + // Generate comment for this specific function + let mut comment = format!( + " // Function signature for: {} ({})", + kebab_name, attr_type + ); + + // For HTTP endpoints, try to extract method and path from attribute + if attr_type == "http" { + if let Some((http_method, http_path)) = extract_http_info(&method.attrs)? { + comment.push_str(&format!("\n // HTTP: {} {}", http_method, http_path)); + } else { + // Default path if not specified + comment.push_str(&format!("\n // HTTP: POST /api/{}", kebab_name)); + } + } + + // Create struct fields that directly represent function parameters + let mut struct_fields = Vec::new(); + + // Add target parameter based on attribute type + if attr_type == "http" { + struct_fields.push(" target: string".to_string()); + } else { + // remote or local + struct_fields.push(" target: address".to_string()); + } + + // Process function parameters (skip &self and &mut self) + for arg in &method.sig.inputs { + if let syn::FnArg::Typed(pat_type) = arg { + if let syn::Pat::Ident(pat_ident) = &*pat_type.pat { + // Skip &self and &mut self + if pat_ident.ident == "self" { + continue; + } + + // Get original param name + let param_orig_name = pat_ident.ident.to_string(); + let method_name_for_error = method.sig.ident.to_string(); // Get method name for error messages + + // Validate parameter name + match validate_name(¶m_orig_name, "Parameter") { + Ok(_) => { + let stripped_param_name = + check_and_strip_leading_underscore(param_orig_name.clone()); // Clone needed + let param_name = to_kebab_case(&stripped_param_name); + + // Rust type to WIT type + match rust_type_to_wit(&pat_type.ty, used_types) { + Ok(param_type) => { + // Add field directly to the struct + struct_fields + .push(format!(" {}: {}", param_name, param_type)); + } + Err(e) => { + // Wrap parameter type conversion error with context + return Err(e.wrap_err(format!( + "Failed to convert type for parameter '{}' in function '{}'", + param_orig_name, method_name_for_error + ))); + } + } + } + Err(e) => { + // Return the error directly + return Err(e); + } + } + } + } + } + + // HTTP handlers no longer require parameters - they can have zero parameters + + // Add return type field + match &method.sig.output { + syn::ReturnType::Type(_, ty) => match rust_type_to_wit(&*ty, used_types) { + Ok(return_type) => { + // Check if the return type is "_", which signifies a standalone () return type. + if return_type == "_" { + let method_name = method.sig.ident.to_string(); + bail!( + "Function '{}' returns '()', which is not directly supported in WIT signatures. \ + Consider returning a Result<(), YourErrorType> or another meaningful type.", + method_name + ); + } + // Add the valid return type field + struct_fields.push(format!(" returning: {}", return_type)); + } + Err(e) => { + // Propagate *other* errors from return type conversion, wrapping them. + let method_name = method.sig.ident.to_string(); + return Err(e.wrap_err(format!( + "Failed to convert return type for function '{}'", + method_name + ))); + } + }, + syn::ReturnType::Default => { + // Functions exposed via WIT must have an explicit return type. + let method_name = method.sig.ident.to_string(); + bail!( + "Function '{}' must have an explicit return type (e.g., '-> MyType' or '-> Result<(), YourErrorType>') to be exposed via WIT. Implicit return types are not allowed.", + method_name + ); + } + } + // Combine everything into a record definition + let record_def = format!( + "{}\n record {} {{\n{}\n }}", + comment, + signature_struct_name, + struct_fields.join(",\n") + ); + + Ok(record_def) +} + +// Helper function to extract HTTP method and path from [http] attribute +#[instrument(level = "trace", skip_all)] +fn extract_http_info(attrs: &[Attribute]) -> Result> { + for attr in attrs { + if attr.path().is_ident("http") { + // Convert attribute to string representation for parsing + let attr_str = format!("{:?}", attr); + debug!(attr_str = %attr_str, "HTTP attribute string"); + + let mut method = None; + let mut path = None; + + // Look for method parameter + if let Some(method_pos) = attr_str.find("method") { + if let Some(eq_pos) = attr_str[method_pos..].find('=') { + let start_pos = method_pos + eq_pos + 1; + // Find the quoted value + if let Some(quote_start) = attr_str[start_pos..].find('"') { + let value_start = start_pos + quote_start + 1; + if let Some(quote_end) = attr_str[value_start..].find('"') { + method = + Some(attr_str[value_start..value_start + quote_end].to_string()); + } + } + } + } + + // Look for path parameter + if let Some(path_pos) = attr_str.find("path") { + if let Some(eq_pos) = attr_str[path_pos..].find('=') { + let start_pos = path_pos + eq_pos + 1; + // Find the quoted value + if let Some(quote_start) = attr_str[start_pos..].find('"') { + let value_start = start_pos + quote_start + 1; + if let Some(quote_end) = attr_str[value_start..].find('"') { + path = Some(attr_str[value_start..value_start + quote_end].to_string()); + } + } + } + } + + // If we found at least one parameter, return the info + if method.is_some() || path.is_some() { + let final_method = method.unwrap_or_else(|| "POST".to_string()); + let final_path = path.unwrap_or_else(|| "/api".to_string()); + return Ok(Some((final_method, final_path))); + } + } + } + Ok(None) +} + +// Helper trait to get TypePath from Type +trait AsTypePath { + fn as_type_path(&self) -> Option<&syn::TypePath>; +} + +impl AsTypePath for syn::Type { + fn as_type_path(&self) -> Option<&syn::TypePath> { + match self { + syn::Type::Path(tp) => Some(tp), + _ => None, + } + } +} + +// Process a single Rust project and generate WIT files +#[instrument(level = "trace", skip_all)] +fn process_rust_project(project_path: &Path, api_dir: &Path) -> Result> { + debug!(project_path = %project_path.display(), "Processing project"); + + // --- 0. Setup & Find Project Files --- + let lib_rs = project_path.join("src").join("lib.rs"); + if !lib_rs.exists() { + warn!(project_path = %project_path.display(), "No lib.rs found, skipping project"); + return Ok(None); + } + let rust_files = find_rust_files(project_path); + if rust_files.is_empty() { + warn!(project_path=%project_path.display(), "No Rust files found in src/, skipping project"); + return Ok(None); + } + let lib_content = fs::read_to_string(&lib_rs).with_context(|| { + format!( + "Failed to read lib.rs for project: {}", + project_path.display() + ) + })?; + let ast = syn::parse_file(&lib_content).with_context(|| { + format!( + "Failed to parse lib.rs for project: {}", + project_path.display() + ) + })?; + + // --- 1. Find Hyperprocess Impl Block & Extract Metadata --- + let mut wit_world = None; + let mut interface_name = None; // Original Rust name (e.g., MyProcessState) + let mut kebab_interface_name = None; // Kebab-case name (e.g., my-process) + let mut impl_item_with_hyperprocess = None; + + debug!("Scanning lib.rs for impl block with #[hyperprocess] attribute"); + for item in &ast.items { + if let Item::Impl(impl_item) = item { + if let Some(attr) = impl_item + .attrs + .iter() + .find(|a| a.path().is_ident("hyperprocess")) + { + debug!("Found #[hyperprocess] attribute"); + // Attempt to extract wit_world. Propagate error if extraction fails. + let world_name = extract_wit_world(&[attr.clone()]) + .wrap_err("Failed to extract wit_world from #[hyperprocess] attribute")?; + debug!(wit_world = %world_name, "Extracted wit_world"); + wit_world = Some(world_name); + + // Get the struct name from the 'impl MyStruct for ...' part + interface_name = impl_item + .self_ty + .as_ref() + .as_type_path() + .and_then(|tp| tp.path.segments.last().map(|seg| seg.ident.to_string())); + + if let Some(ref name) = interface_name { + // Validate original name first + match validate_name(name, "Interface") { + Ok(_) => { + let base_name = remove_state_suffix(name); + kebab_interface_name = Some(to_kebab_case(&base_name)); + debug!(interface_name = %name, base_name = %base_name, kebab_name = ?kebab_interface_name, "Interface details"); + impl_item_with_hyperprocess = Some(impl_item.clone()); + break; // Found the target impl block + } + Err(e) => { + // Escalate errors for invalid interface names instead of just warning + return Err(e.wrap_err(format!( + "Invalid interface name '{}' in hyperprocess impl block", + name + ))); + } + } + } else { + // If interface name couldn't be extracted, it's an error for this project. + bail!("Could not extract interface name from #[hyperprocess] impl block type: {:?}", impl_item.self_ty); + } + } + } + } + + // Exit early if no valid hyperprocess impl block was identified + let Some(ref impl_item) = impl_item_with_hyperprocess else { + // If we looped through everything and didn't find a block (and didn't error above), + // it means no #[hyperprocess] attribute was found at all. This is okay, just skip. + warn!(project_path=%project_path.display(), "No #[hyperprocess] impl block found in lib.rs, skipping project"); + return Ok(None); + }; + // These unwraps are safe due to the checks above ensuring we error or break successfully + let kebab_name = kebab_interface_name.as_ref().unwrap(); + let current_wit_world = wit_world.as_ref().unwrap(); + + // --- 2. Collect Signatures & Initial Types --- + let mut signature_structs = Vec::new(); // Stores WIT string for each signature record + let mut global_used_types = HashSet::new(); // All custom WIT types encountered (kebab-case) + + debug!("Analyzing functions in hyperprocess impl block"); + for item in &impl_item.items { + if let ImplItem::Fn(method) = item { + let method_name = method.sig.ident.to_string(); + debug!(method_name = %method_name, "Examining method"); + + let has_remote = method.attrs.iter().any(|a| a.path().is_ident("remote")); + let has_local = method.attrs.iter().any(|a| a.path().is_ident("local")); + let has_http = method.attrs.iter().any(|a| a.path().is_ident("http")); + let has_init = method.attrs.iter().any(|a| a.path().is_ident("init")); + let has_ws = method.attrs.iter().any(|a| a.path().is_ident("ws")); + + if has_remote || has_local || has_http || has_init || has_ws { + debug!(remote=%has_remote, local=%has_local, http=%has_http, init=%has_init, ws=%has_ws, "Method attributes found"); + // Validate original Rust function name + validate_name(&method_name, "Function")?; // Error early if name invalid + let func_kebab_name = to_kebab_case(&method_name); + + if has_init { + debug!(method_name = %method_name, "Found [init] function, skipping signature generation"); + continue; + } + + if has_ws { + debug!(method_name = %method_name, "Found [ws] function, skipping signature generation (websocket handlers are ignored by WIT generator)"); + continue; + } + + // Generate signature structs. `generate_signature_struct` calls `rust_type_to_wit`, + // which populates `global_used_types` with all custom types found in parameters/return types. + if has_remote { + let sig_struct = generate_signature_struct( + &func_kebab_name, + "remote", + method, + &mut global_used_types, + )?; + signature_structs.push(sig_struct); + } + if has_local { + let sig_struct = generate_signature_struct( + &func_kebab_name, + "local", + method, + &mut global_used_types, + )?; + signature_structs.push(sig_struct); + } + if has_http { + let sig_struct = generate_signature_struct( + &func_kebab_name, + "http", + method, + &mut global_used_types, + )?; + signature_structs.push(sig_struct); + } + } else { + // Method in hyperprocess impl lacks required attribute - Error + return Err(eyre!( + "Method '{}' in the #[hyperprocess] impl block is missing a required attribute ([remote], [local], [http], [init], or [ws]). Only methods with these attributes should be included.", + method_name + )); + } + } + } + debug!(signature_count = %signature_structs.len(), initial_used_types = ?global_used_types, "Completed signature analysis"); + + // --- 3. Resolve & Generate Type Definitions Iteratively --- + debug!("Starting iterative type definition resolution"); + let mut generated_type_defs = HashMap::new(); // Kebab-case name -> WIT definition string + let mut types_to_find_queue: Vec = global_used_types // Initialize queue + .iter() + .filter(|ty| !is_wit_primitive_or_builtin(ty)) // Only custom types + .cloned() + .collect(); + let mut processed_types = HashSet::new(); // Track types processed to avoid cycles/redundancy + + // Add primitives/builtins to processed_types initially + for ty in &global_used_types { + if is_wit_primitive_or_builtin(ty) { + processed_types.insert(ty.clone()); + } + } + + while let Some(type_name_to_find) = types_to_find_queue.pop() { + if processed_types.contains(&type_name_to_find) { + continue; // Already processed or known primitive/builtin + } + + debug!(type_name = %type_name_to_find, "Attempting to find definition"); + let mut definition_found_in_project = false; + + // Search across all project files for the definition + for file_path in &rust_files { + // Directly propagate errors from find_and_make_wit_type_def + match find_and_make_wit_type_def(file_path, &type_name_to_find, &mut global_used_types)? + { + Some((wit_definition, new_local_deps)) => { + debug!(type_name=%type_name_to_find, file_path=%file_path.display(), "Found definition"); + + // Store the definition. Check for duplicates across files. + if let Some(existing_def) = generated_type_defs + .insert(type_name_to_find.clone(), wit_definition.clone()) + { + // Clone wit_definition here + // Simple string comparison might be too strict if formatting differs slightly. + // But good enough for a warning. + if existing_def != wit_definition { + // Compare with the cloned value + warn!(type_name = %type_name_to_find, "Type definition found in multiple files with different generated content. Using the one from: {}", file_path.display()); + } + } + processed_types.insert(type_name_to_find.clone()); // Mark as processed + definition_found_in_project = true; + + // Add newly discovered dependencies from this type's definition to the queue + for dep in new_local_deps { + if !processed_types.contains(&dep) && !types_to_find_queue.contains(&dep) { + debug!(dependency = %dep, discovered_by = %type_name_to_find, "Adding new dependency to find queue"); + types_to_find_queue.push(dep); + } + } + // Found the definition for this type, stop searching files for it + break; + } + None => continue, // Not in this file, check next file + } + } + // If after checking all files, the definition wasn't found + if !definition_found_in_project { + debug!(type_name=%type_name_to_find, "Definition not found in any scanned file."); + // Mark as processed to avoid infinite loop. Verification step will catch this. + processed_types.insert(type_name_to_find.clone()); + } + } + debug!("Finished iterative type definition resolution"); + + // --- 4. Verify All Used Types Have Definitions --- + debug!(final_used_types = ?global_used_types, found_definitions = ?generated_type_defs.keys(), "Starting final verification"); + let mut undefined_types = Vec::new(); + for used_type_name in &global_used_types { + if !is_wit_primitive_or_builtin(used_type_name) + && !generated_type_defs.contains_key(used_type_name) + { + warn!(type_name=%used_type_name, "Verification failed: Used type has no generated definition."); + undefined_types.push(used_type_name.clone()); + } + } + + if !undefined_types.is_empty() { + undefined_types.sort(); + // Use the original project path display for user-friendliness + let project_display = project_path.display(); + bail!( + "WIT Generation Error in project '{}': Found types used (directly or indirectly) in function signatures \ + that are neither WIT built-ins nor defined locally within the scanned project files: {:?}. \ + Ensure definitions for these types (structs/enums) are present in the project's source code \ + (and not skipped due to errors/complexity), or adjust the function/type definitions.", + project_display, + undefined_types + ); + } + debug!("Verification successful: All used types have definitions or are built-in."); + + // --- 5. Generate Final WIT Interface File --- + let mut all_generated_defs: Vec = generated_type_defs.into_values().collect(); + all_generated_defs.sort(); // Sort type definitions for consistent output + signature_structs.sort(); // Sort signature records as well + + if signature_structs.is_empty() && all_generated_defs.is_empty() { + // Use the original interface name if available, otherwise fallback + let name_for_warning = interface_name.as_deref().unwrap_or(""); + warn!(interface_name = %name_for_warning, "No attributed functions or used types requiring definitions found. No WIT interface file generated for this project."); + + // Return the world name even if no interface content is generated, + // so the world file can still be updated/created if necessary. + // But signal that no *interface* was generated by returning None for the interface name part. + return Ok(Some((String::new(), current_wit_world.to_string()))); // Return empty string for interface name + } else { + debug!(kebab_name=%kebab_name, "Generating final WIT content"); + let mut content = String::new(); + + // Add standard imports (can be refined based on actual needs) + content.push_str(" use standard.{address};\n"); // Assuming world includes 'standard' + + // Add type definitions + if !all_generated_defs.is_empty() { + content.push('\n'); // Separator + debug!(count=%all_generated_defs.len(), "Adding type definitions to interface"); + content.push_str(&all_generated_defs.join("\n\n")); + content.push('\n'); + } + + // Add signature structs + if !signature_structs.is_empty() { + content.push('\n'); // Separator + debug!(count=%signature_structs.len(), "Adding signature structs to interface"); + content.push_str(&signature_structs.join("\n\n")); + } + + // Wrap in interface block + let final_content = format!("interface {} {{\n{}\n}}\n", kebab_name, content.trim()); // Trim any trailing whitespace + debug!(interface_name = %interface_name.as_ref().unwrap(), signature_count = %signature_structs.len(), type_def_count = %all_generated_defs.len(), "Generated interface content"); + + // Write the interface file + let interface_file = api_dir.join(format!("{}.wit", kebab_name)); + debug!(path = %interface_file.display(), "Writing WIT file"); + fs::write(&interface_file, &final_content).with_context(|| { + format!( + "Failed to write WIT interface file: {}", + interface_file.display() + ) + })?; + debug!("Successfully wrote WIT file"); + + // If content was generated, return the kebab name for the import statement + debug!(interface = %kebab_name, wit_world=%current_wit_world, "Returning import statement info"); + Ok(Some(( + kebab_name.to_string(), + current_wit_world.to_string(), + ))) + } +} + +#[instrument(level = "trace", skip_all)] +fn rewrite_wit( + api_dir: &Path, + new_imports: &Vec, + wit_worlds: &mut HashSet, + updated_world: &mut bool, +) -> Result<()> { + debug!(api_dir = %api_dir.display(), "Rewriting WIT world files"); + // handle existing api files + for entry in WalkDir::new(api_dir) + .max_depth(1) + .into_iter() + .filter_map(Result::ok) + { + let path = entry.path(); + + if path.is_file() && path.extension().map_or(false, |ext| ext == "wit") { + debug!(path = %path.display(), "Checking WIT file"); + + let Ok(content) = fs::read_to_string(path) else { + continue; + }; + if !content.contains("world ") { + continue; + } + debug!("Found world definition file"); + + // Extract the world name and existing imports + let lines: Vec<&str> = content.lines().collect(); + let mut world_name = None; + let mut existing_imports = Vec::new(); + let mut include_lines = HashSet::new(); + + for line in &lines { + let trimmed = line.trim(); + + if trimmed.starts_with("world ") { + if let Some(name) = trimmed.split_whitespace().nth(1) { + world_name = Some(name.trim_end_matches(" {").to_string()); + } + } else if trimmed.starts_with("import ") { + existing_imports.push(trimmed.to_string()); + } else if trimmed.starts_with("include ") { + include_lines.insert(trimmed.to_string()); + } + } + + let Some(world_name) = world_name else { + continue; + }; + + debug!(world_name = %world_name, "Extracted world name"); + + // Check if this world name matches the one we're looking for + if wit_worlds.remove(&world_name) || wit_worlds.contains(&world_name[6..]) { + let world_content = generate_wit_file( + &world_name, + new_imports, + &existing_imports, + &mut include_lines, + )?; + + debug!(path = %path.display(), "Writing updated world definition"); + // Write the updated world file + fs::write(path, world_content).with_context(|| { + format!("Failed to write updated world file: {}", path.display()) + })?; + + debug!("Successfully updated world definition"); // INFO -> DEBUG + *updated_world = true; + } + } + } + + // handle non-existing api files + for wit_world in wit_worlds.iter() { + for prefix in ["", "types-"] { + let wit_world = format!("{prefix}{wit_world}"); + let world_content = + generate_wit_file(&wit_world, new_imports, &Vec::new(), &mut HashSet::new())?; + + let path = api_dir.join(format!("{wit_world}.wit")); + debug!(path = %path.display(), wit_world = %wit_world, "Writing new world definition"); + // Write the updated world file + fs::write(&path, world_content).with_context(|| { + format!("Failed to write updated world file: {}", path.display()) + })?; + + debug!("Successfully created new world definition for {wit_world}"); + } + *updated_world = true; + } + + Ok(()) +} + +fn generate_wit_file( + world_name: &str, + new_imports: &Vec, + existing_imports: &Vec, + include_lines: &mut HashSet, +) -> Result { + // Determine the include line based on world name + // If world name starts with "types-", use "include lib;" instead + if world_name.starts_with("types-") { + if !include_lines.contains("include lib;") { + include_lines.insert("include lib;".to_string()); + } + } else { + // Keep existing include or default to process-v1 + if include_lines.is_empty() { + include_lines.insert("include process-v1;".to_string()); + } + } + + // Combine existing imports with new imports + let mut all_imports = existing_imports.clone(); + + for import in new_imports { + let import_stmt = import.trim(); + if !all_imports.iter().any(|i| i.trim() == import_stmt) { + all_imports.push(import_stmt.to_string()); + } + } + + // Make sure all imports have proper indentation + let all_imports_with_indent: Vec = all_imports + .iter() + .map(|import| { + if import.starts_with(" ") { + import.clone() + } else { + format!(" {}", import.trim()) + } + }) + .collect(); + + let imports_section = all_imports_with_indent.join("\n"); + + // Create updated world content with proper indentation + let include_lines: String = include_lines.iter().map(|l| format!(" {l}\n")).collect(); + let world_content = format!("world {world_name} {{\n{imports_section}\n{include_lines}}}"); + + return Ok(world_content); +} + +// Generate WIT files from Rust code +#[instrument(level = "trace", skip_all)] +pub fn generate_wit_files(base_dir: &Path, api_dir: &Path) -> Result<(Vec, Vec)> { + // Keep INFO for start + info!("Generating WIT files..."); + fs::create_dir_all(&api_dir)?; + + // Find all relevant Rust projects + let projects = find_rust_projects(base_dir); + let mut processed_projects = Vec::new(); + + if projects.is_empty() { + warn!("No relevant Rust projects found."); + return Ok((Vec::new(), Vec::new())); + } + + // Process each project and collect world imports + let mut new_imports = Vec::new(); + let mut interfaces = Vec::new(); // Kebab-case interface names + + let mut wit_worlds = HashSet::new(); // Collect all unique world names encountered + for project_path in &projects { + match process_rust_project(project_path, api_dir) { + // Project processed successfully, yielding an interface name and world name + Ok(Some((interface, wit_world))) => { + // Only add import if an interface name was actually generated + if !interface.is_empty() { + new_imports.push(format!(" import {interface};")); + interfaces.push(interface); // Add to list of generated interfaces + } else { + // Log if processing succeeded but generated no interface content + debug!(project = %project_path.display(), world = %wit_world, "Project processed but generated no interface content (only types/no functions?)"); + } + // Always record the project path and the target world + processed_projects.push(project_path.clone()); + wit_worlds.insert(wit_world); + } + // Project was skipped intentionally (e.g., no lib.rs, no #[hyperprocess]) + Ok(None) => { + debug!(project = %project_path.display(), "Project skipped during processing (e.g., no lib.rs or #[hyperprocess] found)"); + // Continue to the next project + continue; + } + // An error occurred during processing + Err(e) => { + // Propagate the error, stopping the entire generation process + bail!("Error processing project {}: {}", project_path.display(), e); + } + } + } + + debug!(count = %new_imports.len(), "Collected number of new imports"); + if new_imports.is_empty() && wit_worlds.is_empty() { + info!( + "No WIT interfaces generated and no target WIT worlds identified across all projects." + ); + return Ok((processed_projects, interfaces)); // Return empty interfaces list + } else if new_imports.is_empty() { + info!( + "No new WIT interfaces generated, but target WIT world(s) identified: {:?}", + wit_worlds + ); + // Proceed to rewrite world files even without new imports, as existing ones might need updates/creation. + } + + // Update or create WIT world files + debug!("Processing WIT world files for: {:?}", wit_worlds); + let mut updated_world = false; // Track if any world file was written/updated + + rewrite_wit( + api_dir, + &new_imports, + &mut wit_worlds.clone(), + &mut updated_world, + )?; // Pass a clone as rewrite_wit might modify it + + // If no world file was updated/created yet AND we have imports, create a default one. + if !updated_world && !new_imports.is_empty() { + // Define default world name + let default_world = "async-app-template-dot-os-v0"; + warn!(default_world = %default_world, "No existing world definitions found or created for collected imports, creating default world file"); + + // Determine include based on world name + let include_line = if default_world.starts_with("types-") { + "include lib;" + } else { + "include process-v1;" + }; + + let mut includes = HashSet::new(); + includes.insert(include_line.to_string()); + + // Generate content using the helper function + let world_content = + generate_wit_file(default_world, &new_imports, &Vec::new(), &mut includes)?; + + let world_file = api_dir.join(format!("{}.wit", default_world)); + debug!(path = %world_file.display(), "Writing default world definition"); + + fs::write(&world_file, world_content).with_context(|| { + format!( + "Failed to write default world file: {}", + world_file.display() + ) + })?; + + debug!("Successfully created default world definition"); + updated_world = true; // Mark that a world file was indeed created + } + + if !updated_world { + info!("No world files were updated or created (either no imports needed adding, target worlds already existed/updated, or no default was needed)."); + } + + info!("WIT file generation process completed."); + Ok((processed_projects, interfaces)) // Return list of successfully processed projects and generated interfaces +} diff --git a/src/build_start_package/mod.rs b/src/build_start_package/mod.rs index d3707196..6e5469ff 100644 --- a/src/build_start_package/mod.rs +++ b/src/build_start_package/mod.rs @@ -22,6 +22,7 @@ pub async fn execute( local_dependencies: Vec, add_paths_to_api: Vec, rewrite: bool, + hyperapp: bool, reproducible: bool, force: bool, verbose: bool, @@ -40,6 +41,7 @@ pub async fn execute( local_dependencies, add_paths_to_api, rewrite, + hyperapp, reproducible, force, verbose, diff --git a/src/main.rs b/src/main.rs index 87574074..50957dd2 100644 --- a/src/main.rs +++ b/src/main.rs @@ -153,6 +153,10 @@ async fn execute( let is_persist = matches.get_one::("PERSIST").unwrap(); let release = matches.get_one::("RELEASE").unwrap(); let verbosity = matches.get_one::("VERBOSITY").unwrap(); + let args = matches + .get_one::("ARGS") + .map(|s| s.split_whitespace().map(String::from).collect()) + .unwrap_or_else(|| vec![]); println!("boot_fake_node: {runtime_path:?}"); boot_fake_node::execute( @@ -167,7 +171,7 @@ async fn execute( *is_persist, *release, *verbosity, - vec![], + args, ) .await } @@ -184,6 +188,10 @@ async fn execute( // let password = matches.get_one::("PASSWORD").unwrap(); // TODO: with develop 0.8.0 let release = matches.get_one::("RELEASE").unwrap(); let verbosity = matches.get_one::("VERBOSITY").unwrap(); + let args = matches + .get_one::("ARGS") + .map(|s| s.split_whitespace().map(String::from).collect()) + .unwrap_or_else(|| vec![]); boot_real_node::execute( runtime_path, @@ -194,7 +202,7 @@ async fn execute( // password, // TODO: with develop 0.8.0 *release, *verbosity, - vec![], + args, ) .await } @@ -235,6 +243,7 @@ async fn execute( .map(|s| PathBuf::from(s)) .collect(); let rewrite = matches.get_one::("REWRITE").unwrap(); + let hyperapp = matches.get_one::("HYPERAPP").unwrap(); let reproducible = matches.get_one::("REPRODUCIBLE").unwrap(); let force = matches.get_one::("FORCE").unwrap(); let verbose = matches.get_one::("VERBOSE").unwrap(); @@ -253,6 +262,7 @@ async fn execute( local_dependencies, add_paths_to_api, *rewrite, + *hyperapp, *reproducible, *force, *verbose, @@ -298,6 +308,7 @@ async fn execute( .map(|s| PathBuf::from(s)) .collect(); let rewrite = matches.get_one::("REWRITE").unwrap(); + let hyperapp = matches.get_one::("HYPERAPP").unwrap(); let reproducible = matches.get_one::("REPRODUCIBLE").unwrap(); let force = matches.get_one::("FORCE").unwrap(); let verbose = matches.get_one::("VERBOSE").unwrap(); @@ -316,6 +327,7 @@ async fn execute( local_dependencies, add_paths_to_api, *rewrite, + *hyperapp, *reproducible, *force, *verbose, @@ -391,6 +403,9 @@ async fn execute( .and_then(|kp| Some(PathBuf::from(kp))); let ledger = matches.get_one::("LEDGER").unwrap(); let trezor = matches.get_one::("TREZOR").unwrap(); + let safe = matches + .get_one::("SAFE_CONTRACT_ADDRESS") + .and_then(|gs| Some(gs.as_str())); let rpc_uri = matches.get_one::("RPC_URI").unwrap(); let real = matches.get_one::("REAL").unwrap(); let unpublish = matches.get_one::("UNPUBLISH").unwrap(); @@ -409,6 +424,7 @@ async fn execute( keystore_path, ledger, trezor, + safe, rpc_uri, real, unpublish, @@ -574,7 +590,7 @@ async fn make_app(current_dir: &std::ffi::OsString) -> Result { .arg(Arg::new("RPC_ENDPOINT") .action(ArgAction::Set) .long("rpc") - .help("Ethereum Optimism mainnet RPC endpoint (wss://)") + .help("Ethereum Base mainnet RPC endpoint (wss://)") .required(false) ) .arg(Arg::new("PERSIST") @@ -602,6 +618,13 @@ async fn make_app(current_dir: &std::ffi::OsString) -> Result { .default_value("0") .value_parser(value_parser!(u8)) ) + .arg(Arg::new("ARGS") + .action(ArgAction::Set) + .num_args(1..) + .last(true) // Collect everything after -- + .help("Additional arguments to pass to the node (i.e. to Hyperdrive)") + .required(false) + ) ) .subcommand(Command::new("boot-real-node") .about("Boot a real node") @@ -651,7 +674,7 @@ async fn make_app(current_dir: &std::ffi::OsString) -> Result { .arg(Arg::new("RPC_ENDPOINT") .action(ArgAction::Set) .long("rpc") - .help("Ethereum Optimism mainnet RPC endpoint (wss://)") + .help("Ethereum Base mainnet RPC endpoint (wss://)") .required(false) ) //.arg(Arg::new("PASSWORD") // TODO: with develop 0.8.0 @@ -673,6 +696,13 @@ async fn make_app(current_dir: &std::ffi::OsString) -> Result { .default_value("0") .value_parser(value_parser!(u8)) ) + .arg(Arg::new("ARGS") + .action(ArgAction::Set) + .num_args(1..) + .last(true) // Collect everything after -- + .help("Additional arguments to pass to the node (i.e. to Hyperdrive)") + .required(false) + ) ) .subcommand(Command::new("build") .about("Build a Hyperware package") @@ -755,7 +785,13 @@ async fn make_app(current_dir: &std::ffi::OsString) -> Result { .arg(Arg::new("REWRITE") .action(ArgAction::SetTrue) .long("rewrite") - .help("Rewrite the package (disables `Spawn!()`) [default: don't rewrite]") + .help("Rewrite the package (enables `Spawn!()`) [default: don't rewrite]") + .required(false) + ) + .arg(Arg::new("HYPERAPP") + .action(ArgAction::SetTrue) + .long("hyperapp") + .help("Build using the Hyperapp framework [default: don't use Hyperapp framework]") .required(false) ) .arg(Arg::new("REPRODUCIBLE") @@ -861,8 +897,14 @@ async fn make_app(current_dir: &std::ffi::OsString) -> Result { ) .arg(Arg::new("REWRITE") .action(ArgAction::SetTrue) - .long("no-rewrite") - .help("Rewrite the package (disables `Spawn!()`) [default: don't rewrite]") + .long("rewrite") + .help("Rewrite the package (enables `Spawn!()`) [default: don't rewrite]") + .required(false) + ) + .arg(Arg::new("HYPERAPP") + .action(ArgAction::SetTrue) + .long("hyperapp") + .help("Build using the Hyperapp framework [default: don't use Hyperapp framework]") .required(false) ) .arg(Arg::new("REPRODUCIBLE") @@ -1072,21 +1114,28 @@ async fn make_app(current_dir: &std::ffi::OsString) -> Result { .action(ArgAction::Set) .short('k') .long("keystore-path") - .help("Path to private key keystore (choose 1 of `k`, `l`, `t`)") // TODO: add link to docs? + .help("Path to private key keystore (choose 1 of `k`, `l`, `t`, `s`)") // TODO: add link to docs? .required(false) ) .arg(Arg::new("LEDGER") .action(ArgAction::SetTrue) .short('l') .long("ledger") - .help("Use Ledger private key (choose 1 of `k`, `l`, `t`)") + .help("Use Ledger private key (choose 1 of `k`, `l`, `t`, `s`)") .required(false) ) .arg(Arg::new("TREZOR") .action(ArgAction::SetTrue) .short('t') .long("trezor") - .help("Use Trezor private key (choose 1 of `k`, `l`, `t`)") + .help("Use Trezor private key (choose 1 of `k`, `l`, `t`, `s`)") + .required(false) + ) + .arg(Arg::new("SAFE_CONTRACT_ADDRESS") + .action(ArgAction::Set) + .short('s') + .long("safe") + .help("Create transaction for Safe (choose 1 of `k`, `l`, `t`, `s`)") .required(false) ) .arg(Arg::new("URI") @@ -1100,7 +1149,7 @@ async fn make_app(current_dir: &std::ffi::OsString) -> Result { .action(ArgAction::Set) .short('r') .long("rpc") - .help("Ethereum Optimism mainnet RPC endpoint (wss://)") + .help("Ethereum Base mainnet RPC endpoint (wss://)") .required(true) ) .arg(Arg::new("REAL") diff --git a/src/new/mod.rs b/src/new/mod.rs index 86b6013f..1b5f1e82 100644 --- a/src/new/mod.rs +++ b/src/new/mod.rs @@ -283,12 +283,12 @@ pub fn execute( if !is_hypermap_safe(&package_name, false) { let error = if !is_from_dir { eyre!( - "`package_name` '{}' must be Kimap safe (a-z, 0-9, - allowed).", + "`package_name` '{}' must be Hypermap safe (a-z, 0-9, - allowed).", package_name ) } else { eyre!( - "`package_name` (derived from given directory {:?}) '{}' must be Kimap safe (a-z, 0-9, - allowed).", + "`package_name` (derived from given directory {:?}) '{}' must be Hypermap safe (a-z, 0-9, - allowed).", new_dir, package_name, ) @@ -297,7 +297,7 @@ pub fn execute( } if !is_hypermap_safe(&publisher, true) { return Err(eyre!( - "`publisher` '{}' must be Kimap safe (a-z, 0-9, -, . allowed).", + "`publisher` '{}' must be Hypermap safe (a-z, 0-9, -, . allowed).", publisher )); } diff --git a/src/new/templates/rust/no-ui/blank/blank/src/lib.rs b/src/new/templates/rust/no-ui/blank/blank/src/lib.rs index d3ef2112..df935449 100644 --- a/src/new/templates/rust/no-ui/blank/blank/src/lib.rs +++ b/src/new/templates/rust/no-ui/blank/blank/src/lib.rs @@ -1,7 +1,7 @@ use hyperware_process_lib::{await_message, call_init, println, Address}; wit_bindgen::generate!({ - path: "target/wit", + path: "../target/wit", world: "process-v1", }); diff --git a/src/new/templates/rust/no-ui/chat/chat/src/lib.rs b/src/new/templates/rust/no-ui/chat/chat/src/lib.rs index 1a4988f4..f5089f9f 100644 --- a/src/new/templates/rust/no-ui/chat/chat/src/lib.rs +++ b/src/new/templates/rust/no-ui/chat/chat/src/lib.rs @@ -4,10 +4,12 @@ use crate::hyperware::process::chat::{ ChatMessage, Request as ChatRequest, Response as ChatResponse, SendRequest, }; use hyperware_process_lib::logging::{error, info, init_logging, Level}; -use hyperware_process_lib::{await_message, call_init, println, Address, Message, Request, Response}; +use hyperware_process_lib::{ + await_message, call_init, println, Address, Message, Request, Response, +}; wit_bindgen::generate!({ - path: "target/wit", + path: "../target/wit", world: "chat-template-dot-os-v0", generate_unused_types: true, additional_derives: [serde::Deserialize, serde::Serialize, process_macros::SerdeJsonInto], diff --git a/src/new/templates/rust/no-ui/chat/send/src/lib.rs b/src/new/templates/rust/no-ui/chat/send/src/lib.rs index 19942550..ea2a31a6 100644 --- a/src/new/templates/rust/no-ui/chat/send/src/lib.rs +++ b/src/new/templates/rust/no-ui/chat/send/src/lib.rs @@ -1,8 +1,12 @@ -use crate::hyperware::process::chat::{Request as ChatRequest, Response as ChatResponse, SendRequest}; -use hyperware_process_lib::{await_next_message_body, call_init, println, Address, Message, Request}; +use crate::hyperware::process::chat::{ + Request as ChatRequest, Response as ChatResponse, SendRequest, +}; +use hyperware_process_lib::{ + await_next_message_body, call_init, println, Address, Message, Request, +}; wit_bindgen::generate!({ - path: "target/wit", + path: "../target/wit", world: "chat-template-dot-os-v0", generate_unused_types: true, additional_derives: [serde::Deserialize, serde::Serialize], diff --git a/src/new/templates/rust/no-ui/chat/test/chat-test/chat-test/src/lib.rs b/src/new/templates/rust/no-ui/chat/test/chat-test/chat-test/src/lib.rs index c0ebfcfb..41dbdc88 100644 --- a/src/new/templates/rust/no-ui/chat/test/chat-test/chat-test/src/lib.rs +++ b/src/new/templates/rust/no-ui/chat/test/chat-test/chat-test/src/lib.rs @@ -1,18 +1,24 @@ -use crate::hyperware::process::chat::{ChatMessage, Request as ChatRequest, Response as ChatResponse, SendRequest}; -use crate::hyperware::process::tester::{Request as TesterRequest, Response as TesterResponse, RunRequest, FailResponse}; +use crate::hyperware::process::chat::{ + ChatMessage, Request as ChatRequest, Response as ChatResponse, SendRequest, +}; +use crate::hyperware::process::tester::{ + FailResponse, Request as TesterRequest, Response as TesterResponse, RunRequest, +}; -use hyperware_process_lib::{await_message, call_init, print_to_terminal, println, Address, ProcessId, Request, Response}; +use hyperware_process_lib::{ + await_message, call_init, print_to_terminal, println, Address, ProcessId, Request, Response, +}; mod tester_lib; wit_bindgen::generate!({ - path: "target/wit", + path: "../target/wit", world: "chat-test-template-dot-os-v0", generate_unused_types: true, additional_derives: [PartialEq, serde::Deserialize, serde::Serialize, process_macros::SerdeJsonInto], }); -fn handle_message (our: &Address) -> anyhow::Result<()> { +fn handle_message(our: &Address) -> anyhow::Result<()> { let message = await_message().unwrap(); if !message.is_request() { @@ -60,15 +66,19 @@ fn handle_message (our: &Address) -> anyhow::Result<()> { target: node_names[1].clone(), message: message.clone(), })) - .send_and_await_response(15)?.unwrap(); + .send_and_await_response(15)? + .unwrap(); // Get history from receiver & test print_to_terminal(0, "chat_test: c"); let response = Request::new() .target(their_chat_address.clone()) .body(ChatRequest::History(our.node.clone())) - .send_and_await_response(15)?.unwrap(); - if response.is_request() { fail!("chat_test"); }; + .send_and_await_response(15)? + .unwrap(); + if response.is_request() { + fail!("chat_test"); + }; let ChatResponse::History(messages) = response.body().try_into()? else { fail!("chat_test"); }; @@ -96,12 +106,12 @@ fn init(our: Address) { loop { match handle_message(&our) { - Ok(()) => {}, + Ok(()) => {} Err(e) => { print_to_terminal(0, format!("chat_test: error: {e:?}").as_str()); fail!("chat_test"); - }, + } }; } } diff --git a/src/new/templates/rust/no-ui/echo/echo/src/lib.rs b/src/new/templates/rust/no-ui/echo/echo/src/lib.rs index b2d367a6..842ddba7 100644 --- a/src/new/templates/rust/no-ui/echo/echo/src/lib.rs +++ b/src/new/templates/rust/no-ui/echo/echo/src/lib.rs @@ -2,7 +2,7 @@ use hyperware_process_lib::logging::{error, info, init_logging, Level}; use hyperware_process_lib::{await_message, call_init, println, Address, Message, Response}; wit_bindgen::generate!({ - path: "target/wit", + path: "../target/wit", world: "process-v1", }); diff --git a/src/new/templates/rust/no-ui/echo/test/echo-test/echo-test/src/lib.rs b/src/new/templates/rust/no-ui/echo/test/echo-test/echo-test/src/lib.rs index 34d9f9cd..87c15c29 100644 --- a/src/new/templates/rust/no-ui/echo/test/echo-test/echo-test/src/lib.rs +++ b/src/new/templates/rust/no-ui/echo/test/echo-test/echo-test/src/lib.rs @@ -9,7 +9,7 @@ use hyperware_process_lib::{ mod tester_lib; wit_bindgen::generate!({ - path: "target/wit", + path: "../target/wit", world: "echo-test-template-dot-os-v0", generate_unused_types: true, additional_derives: [PartialEq, serde::Deserialize, serde::Serialize, process_macros::SerdeJsonInto], diff --git a/src/new/templates/rust/no-ui/fibonacci/fibonacci/src/lib.rs b/src/new/templates/rust/no-ui/fibonacci/fibonacci/src/lib.rs index 22d97ee2..f9ba4376 100644 --- a/src/new/templates/rust/no-ui/fibonacci/fibonacci/src/lib.rs +++ b/src/new/templates/rust/no-ui/fibonacci/fibonacci/src/lib.rs @@ -5,7 +5,7 @@ use hyperware_process_lib::logging::{error, info, init_logging, Level}; use hyperware_process_lib::{await_message, call_init, Address, Message, Response}; wit_bindgen::generate!({ - path: "target/wit", + path: "../target/wit", world: "fibonacci-template-dot-os-v0", generate_unused_types: true, additional_derives: [serde::Deserialize, serde::Serialize, process_macros::SerdeJsonInto], diff --git a/src/new/templates/rust/no-ui/fibonacci/number/src/lib.rs b/src/new/templates/rust/no-ui/fibonacci/number/src/lib.rs index c5357da5..fd7d42a4 100644 --- a/src/new/templates/rust/no-ui/fibonacci/number/src/lib.rs +++ b/src/new/templates/rust/no-ui/fibonacci/number/src/lib.rs @@ -1,10 +1,12 @@ -use crate::hyperware::process::fibonacci::{Request as FibonacciRequest, Response as FibonacciResponse}; +use crate::hyperware::process::fibonacci::{ + Request as FibonacciRequest, Response as FibonacciResponse, +}; use hyperware_process_lib::{ await_next_message_body, call_init, println, Address, Message, Request, }; wit_bindgen::generate!({ - path: "target/wit", + path: "../target/wit", world: "fibonacci-template-dot-os-v0", generate_unused_types: true, additional_derives: [serde::Deserialize, serde::Serialize], diff --git a/src/new/templates/rust/no-ui/fibonacci/test/fibonacci-test/fibonacci-test/src/lib.rs b/src/new/templates/rust/no-ui/fibonacci/test/fibonacci-test/fibonacci-test/src/lib.rs index c006603a..dc990637 100644 --- a/src/new/templates/rust/no-ui/fibonacci/test/fibonacci-test/fibonacci-test/src/lib.rs +++ b/src/new/templates/rust/no-ui/fibonacci/test/fibonacci-test/fibonacci-test/src/lib.rs @@ -1,12 +1,16 @@ use crate::hyperware::process::fibonacci::{Request as FibRequest, Response as FibResponse}; -use crate::hyperware::process::tester::{Request as TesterRequest, Response as TesterResponse, RunRequest, FailResponse}; +use crate::hyperware::process::tester::{ + FailResponse, Request as TesterRequest, Response as TesterResponse, RunRequest, +}; -use hyperware_process_lib::{await_message, call_init, print_to_terminal, Address, ProcessId, Request, Response}; +use hyperware_process_lib::{ + await_message, call_init, print_to_terminal, Address, ProcessId, Request, Response, +}; mod tester_lib; wit_bindgen::generate!({ - path: "target/wit", + path: "../target/wit", world: "fibonacci-test-template-dot-os-v0", generate_unused_types: true, additional_derives: [PartialEq, serde::Deserialize, serde::Serialize, process_macros::SerdeJsonInto], @@ -16,8 +20,11 @@ fn test_number(n: u32, address: &Address) -> anyhow::Result { let response = Request::new() .target(address) .body(FibRequest::Number(n)) - .send_and_await_response(15)?.unwrap(); - if response.is_request() { fail!("fibonacci_test"); }; + .send_and_await_response(15)? + .unwrap(); + if response.is_request() { + fail!("fibonacci_test"); + }; let FibResponse::Number(fib_number) = response.body().try_into()? else { fail!("fibonacci_test"); }; @@ -28,15 +35,18 @@ fn test_numbers(n: u32, n_trials: u32, address: &Address) -> anyhow::Result let response = Request::new() .target(address) .body(FibRequest::Numbers((n, n_trials))) - .send_and_await_response(15)?.unwrap(); - if response.is_request() { fail!("fibonacci_test"); }; + .send_and_await_response(15)? + .unwrap(); + if response.is_request() { + fail!("fibonacci_test"); + }; let FibResponse::Numbers((fib_number, _)) = response.body().try_into()? else { fail!("fibonacci_test"); }; Ok(fib_number) } -fn handle_message (our: &Address) -> anyhow::Result<()> { +fn handle_message(our: &Address) -> anyhow::Result<()> { let message = await_message().unwrap(); if !message.is_request() { @@ -93,12 +103,12 @@ fn init(our: Address) { loop { match handle_message(&our) { - Ok(()) => {}, + Ok(()) => {} Err(e) => { print_to_terminal(0, format!("fibonacci_test: error: {e:?}").as_str()); fail!("fibonacci_test"); - }, + } }; } } diff --git a/src/new/templates/rust/no-ui/file-transfer/download/src/lib.rs b/src/new/templates/rust/no-ui/file-transfer/download/src/lib.rs index 50cf7d6e..2e99ea04 100644 --- a/src/new/templates/rust/no-ui/file-transfer/download/src/lib.rs +++ b/src/new/templates/rust/no-ui/file-transfer/download/src/lib.rs @@ -5,7 +5,7 @@ use hyperware_process_lib::{ }; wit_bindgen::generate!({ - path: "target/wit", + path: "../target/wit", world: "file-transfer-template-dot-os-v0", generate_unused_types: true, additional_derives: [serde::Deserialize, serde::Serialize, process_macros::SerdeJsonInto], diff --git a/src/new/templates/rust/no-ui/file-transfer/file-transfer-worker-api/src/lib.rs b/src/new/templates/rust/no-ui/file-transfer/file-transfer-worker-api/src/lib.rs index 1b6fa5b9..7d444673 100644 --- a/src/new/templates/rust/no-ui/file-transfer/file-transfer-worker-api/src/lib.rs +++ b/src/new/templates/rust/no-ui/file-transfer/file-transfer-worker-api/src/lib.rs @@ -5,7 +5,7 @@ use crate::hyperware::process::standard::Address as WitAddress; use hyperware_process_lib::{our_capabilities, spawn, Address, OnExit, Request, Response}; wit_bindgen::generate!({ - path: "target/wit", + path: "../target/wit", world: "file-transfer-worker-api-v0", generate_unused_types: true, additional_derives: [serde::Deserialize, serde::Serialize, process_macros::SerdeJsonInto], diff --git a/src/new/templates/rust/no-ui/file-transfer/file-transfer-worker/src/lib.rs b/src/new/templates/rust/no-ui/file-transfer/file-transfer-worker/src/lib.rs index aca5faef..bd87a312 100644 --- a/src/new/templates/rust/no-ui/file-transfer/file-transfer-worker/src/lib.rs +++ b/src/new/templates/rust/no-ui/file-transfer/file-transfer-worker/src/lib.rs @@ -11,7 +11,7 @@ use hyperware_process_lib::{ }; wit_bindgen::generate!({ - path: "target/wit", + path: "../target/wit", world: "file-transfer-template-dot-os-v0", generate_unused_types: true, additional_derives: [serde::Deserialize, serde::Serialize, process_macros::SerdeJsonInto], diff --git a/src/new/templates/rust/no-ui/file-transfer/file-transfer/src/lib.rs b/src/new/templates/rust/no-ui/file-transfer/file-transfer/src/lib.rs index 3a8a70c4..ec8e8715 100644 --- a/src/new/templates/rust/no-ui/file-transfer/file-transfer/src/lib.rs +++ b/src/new/templates/rust/no-ui/file-transfer/file-transfer/src/lib.rs @@ -14,7 +14,7 @@ use hyperware_process_lib::{ }; wit_bindgen::generate!({ - path: "target/wit", + path: "../target/wit", world: "file-transfer-template-dot-os-v0", generate_unused_types: true, additional_derives: [serde::Deserialize, serde::Serialize, process_macros::SerdeJsonInto], diff --git a/src/new/templates/rust/no-ui/file-transfer/list-files/src/lib.rs b/src/new/templates/rust/no-ui/file-transfer/list-files/src/lib.rs index 66076f9f..284a3182 100644 --- a/src/new/templates/rust/no-ui/file-transfer/list-files/src/lib.rs +++ b/src/new/templates/rust/no-ui/file-transfer/list-files/src/lib.rs @@ -1,10 +1,12 @@ use crate::hyperware::process::file_transfer::{ Request as TransferRequest, Response as TransferResponse, }; -use hyperware_process_lib::{await_next_message_body, call_init, println, Address, Message, Request}; +use hyperware_process_lib::{ + await_next_message_body, call_init, println, Address, Message, Request, +}; wit_bindgen::generate!({ - path: "target/wit", + path: "../target/wit", world: "file-transfer-template-dot-os-v0", generate_unused_types: true, additional_derives: [serde::Deserialize, serde::Serialize, process_macros::SerdeJsonInto], diff --git a/src/new/templates/rust/no-ui/file-transfer/test/file-transfer-test/file-transfer-test/src/lib.rs b/src/new/templates/rust/no-ui/file-transfer/test/file-transfer-test/file-transfer-test/src/lib.rs index 8a3ec5b3..19cee03e 100644 --- a/src/new/templates/rust/no-ui/file-transfer/test/file-transfer-test/file-transfer-test/src/lib.rs +++ b/src/new/templates/rust/no-ui/file-transfer/test/file-transfer-test/file-transfer-test/src/lib.rs @@ -17,7 +17,7 @@ use hyperware_process_lib::{ mod tester_lib; wit_bindgen::generate!({ - path: "target/wit", + path: "../target/wit", world: "file-transfer-test-template-dot-os-v0", generate_unused_types: true, additional_derives: [PartialEq, serde::Deserialize, serde::Serialize, process_macros::SerdeJsonInto], diff --git a/src/new/templates/rust/ui/chat/chat/src/lib.rs b/src/new/templates/rust/ui/chat/chat/src/lib.rs index a8d4b455..9a56bb2a 100644 --- a/src/new/templates/rust/ui/chat/chat/src/lib.rs +++ b/src/new/templates/rust/ui/chat/chat/src/lib.rs @@ -14,7 +14,7 @@ use hyperware_process_lib::{ }; wit_bindgen::generate!({ - path: "target/wit", + path: "../target/wit", world: "chat-template-dot-os-v0", generate_unused_types: true, additional_derives: [serde::Deserialize, serde::Serialize, process_macros::SerdeJsonInto], diff --git a/src/new/templates/rust/ui/chat/test/chat-test/chat-test/src/lib.rs b/src/new/templates/rust/ui/chat/test/chat-test/chat-test/src/lib.rs index c0ebfcfb..41dbdc88 100644 --- a/src/new/templates/rust/ui/chat/test/chat-test/chat-test/src/lib.rs +++ b/src/new/templates/rust/ui/chat/test/chat-test/chat-test/src/lib.rs @@ -1,18 +1,24 @@ -use crate::hyperware::process::chat::{ChatMessage, Request as ChatRequest, Response as ChatResponse, SendRequest}; -use crate::hyperware::process::tester::{Request as TesterRequest, Response as TesterResponse, RunRequest, FailResponse}; +use crate::hyperware::process::chat::{ + ChatMessage, Request as ChatRequest, Response as ChatResponse, SendRequest, +}; +use crate::hyperware::process::tester::{ + FailResponse, Request as TesterRequest, Response as TesterResponse, RunRequest, +}; -use hyperware_process_lib::{await_message, call_init, print_to_terminal, println, Address, ProcessId, Request, Response}; +use hyperware_process_lib::{ + await_message, call_init, print_to_terminal, println, Address, ProcessId, Request, Response, +}; mod tester_lib; wit_bindgen::generate!({ - path: "target/wit", + path: "../target/wit", world: "chat-test-template-dot-os-v0", generate_unused_types: true, additional_derives: [PartialEq, serde::Deserialize, serde::Serialize, process_macros::SerdeJsonInto], }); -fn handle_message (our: &Address) -> anyhow::Result<()> { +fn handle_message(our: &Address) -> anyhow::Result<()> { let message = await_message().unwrap(); if !message.is_request() { @@ -60,15 +66,19 @@ fn handle_message (our: &Address) -> anyhow::Result<()> { target: node_names[1].clone(), message: message.clone(), })) - .send_and_await_response(15)?.unwrap(); + .send_and_await_response(15)? + .unwrap(); // Get history from receiver & test print_to_terminal(0, "chat_test: c"); let response = Request::new() .target(their_chat_address.clone()) .body(ChatRequest::History(our.node.clone())) - .send_and_await_response(15)?.unwrap(); - if response.is_request() { fail!("chat_test"); }; + .send_and_await_response(15)? + .unwrap(); + if response.is_request() { + fail!("chat_test"); + }; let ChatResponse::History(messages) = response.body().try_into()? else { fail!("chat_test"); }; @@ -96,12 +106,12 @@ fn init(our: Address) { loop { match handle_message(&our) { - Ok(()) => {}, + Ok(()) => {} Err(e) => { print_to_terminal(0, format!("chat_test: error: {e:?}").as_str()); fail!("chat_test"); - }, + } }; } } diff --git a/src/publish/mod.rs b/src/publish/mod.rs index ef72f055..216bfa6c 100644 --- a/src/publish/mod.rs +++ b/src/publish/mod.rs @@ -309,61 +309,20 @@ async fn prepare_hypermap_put( } #[instrument(level = "trace", skip_all)] -pub async fn execute( - package_dir: &Path, +pub async fn build_tx( metadata_uri: &str, - keystore_path: Option, - ledger: &bool, - trezor: &bool, - rpc_uri: &str, + metadata_hash: &str, + name: &str, + publisher: &str, + provider: &RootProvider, real: &bool, unpublish: &bool, + wallet_address: Address, + chain_id: u64, gas_limit: u64, max_priority_fee_per_gas: Option, max_fee_per_gas: Option, - mock: &bool, -) -> Result<()> { - if !package_dir.join("pkg").exists() { - return Err(eyre!( - "Required `pkg/` dir not found within given input dir {:?} (or cwd, if none given). Please re-run targeting a package.", - package_dir, - )); - } - - let chain_id = if *real { REAL_CHAIN_ID } else { FAKE_CHAIN_ID }; - let (wallet_address, wallet) = match (keystore_path, *ledger, *trezor) { - (Some(ref kp), false, false) => read_keystore(kp)?, - (None, true, false) => read_ledger(chain_id).await?, - (None, false, true) => read_trezor(chain_id).await?, - _ => { - return Err(eyre!( - "Must supply one and only one of `--keystore_path`, `--ledger`, or `--trezor`" - )) - } - }; - - let metadata = read_and_update_metadata(package_dir)?; - - let name = metadata.name.clone().unwrap(); - let publisher = metadata.properties.publisher.clone(); - - if !is_hypermap_safe(&name, false) { - return Err(eyre!( - "The App Store requires package names have only lowercase letters, digits, and `-`s" - )); - } - if !is_hypermap_safe(&publisher, true) { - return Err(eyre!( - "The App Store requires publisher names have only lowercase letters, digits, `-`s, and `.`s" - )); - } - - let metadata_hash = check_remote_metadata(&metadata, metadata_uri, package_dir).await?; - check_pkg_hash(&metadata, package_dir, metadata_uri)?; - - let ws = WsConnect::new(rpc_uri); - let provider: RootProvider = ProviderBuilder::default().on_ws(ws).await?; - +) -> Result<(Address, Vec, TransactionRequest)> { let hypermap = Address::from_str(if *real { REAL_KIMAP_ADDRESS } else { @@ -391,7 +350,7 @@ pub async fn execute( prepare_hypermap_put( multicall, - name.clone(), + name.to_string(), &publisher, hypermap, &provider, @@ -410,7 +369,7 @@ pub async fn execute( let tx = TransactionRequest::default() .to(to) - .input(TransactionInput::new(call.into())) + .input(TransactionInput::new(call.clone().into())) .nonce(nonce) .with_chain_id(chain_id) .with_gas_limit(gas_limit) @@ -419,21 +378,153 @@ pub async fn execute( ) .with_max_fee_per_gas(max_fee_per_gas.unwrap_or_else(|| suggested_max_fee_per_gas)); - let tx_envelope = tx.build(&wallet).await?; - let tx_encoded = tx_envelope.encoded_2718(); - if *mock { + Ok((to, call, tx)) +} + +#[instrument(level = "trace", skip_all)] +pub async fn execute( + package_dir: &Path, + metadata_uri: &str, + keystore_path: Option, + ledger: &bool, + trezor: &bool, + safe: Option<&str>, + rpc_uri: &str, + real: &bool, + unpublish: &bool, + gas_limit: u64, + max_priority_fee_per_gas: Option, + max_fee_per_gas: Option, + mock: &bool, +) -> Result<()> { + if !package_dir.join("pkg").exists() { + return Err(eyre!( + "Required `pkg/` dir not found within given input dir {:?} (or cwd, if none given). Please re-run targeting a package.", + package_dir, + )); + } + + let metadata = read_and_update_metadata(package_dir)?; + + let name = metadata.name.clone().unwrap(); + let publisher = metadata.properties.publisher.clone(); + + if !is_hypermap_safe(&name, false) { + return Err(eyre!( + "The App Store requires package names have only lowercase letters, digits, and `-`s" + )); + } + if !is_hypermap_safe(&publisher, true) { + return Err(eyre!( + "The App Store requires publisher names have only lowercase letters, digits, `-`s, and `.`s" + )); + } + + let metadata_hash = check_remote_metadata(&metadata, metadata_uri, package_dir).await?; + if !unpublish { + check_pkg_hash(&metadata, package_dir, metadata_uri)?; + } + + let chain_id = if *real { REAL_CHAIN_ID } else { FAKE_CHAIN_ID }; + + let is_safe_tx = safe.is_some(); + + let (wallet_address, wallet) = if is_safe_tx { + // In Safe mode, we don't need a wallet for signing + // Parse the Safe address provided by the user + let safe_address = Address::from_str(safe.unwrap())?; + (safe_address, None) + } else { + // Traditional wallet mode + let (addr, wallet) = match (keystore_path, *ledger, *trezor) { + (Some(ref kp), false, false) => read_keystore(kp)?, + (None, true, false) => read_ledger(chain_id).await?, + (None, false, true) => read_trezor(chain_id).await?, + _ => { + return Err(eyre!( + "Must supply one and only one of `--keystore_path`, `--ledger`, `--trezor`, or `--safe`" + )) + } + }; + (addr, Some(wallet)) + }; + + let ws = WsConnect::new(rpc_uri); + let provider: RootProvider = ProviderBuilder::default().on_ws(ws).await?; + + let (to, call, tx) = build_tx( + metadata_uri, + &metadata_hash, + &name, + &publisher, + &provider, + real, + unpublish, + wallet_address, + chain_id, + gas_limit, + max_priority_fee_per_gas, + max_fee_per_gas, + ) + .await?; + + if is_safe_tx { + // Generate Safe transaction data + let tx_data = hex::encode(call); + + // TODO: can we get URL working? If so this is by far preferable + //// Create Safe App URL - always use Base chain (8453) + //let safe_url = format!( + // "https://app.safe.global/base:{}/transactions/tx?safe={}&to={}&value=0&data=0x{}", + // wallet_address, + // wallet_address, + // to, + // tx_data + //); + + info!("=== Safe Transaction Data ==="); + // TODO: can we get URL working? If so this is by far preferable + //info!("Safe App URL (click or copy):"); + //info!("{}", make_remote_link(&safe_url, &safe_url)); + //info!(""); + info!("Manual Steps:"); + info!("1. Go to your Safe at https://app.safe.global"); + info!("2. Click \"New Transaction\" → \"Transaction Builder\""); info!( - "{} {name} tx mock successful", - if *unpublish { "unpublish" } else { "publish" } + "3. Enter the contract address in \"Enter Address or ENS Name\": {}", + to ); - } else { - let tx = provider.send_raw_transaction(&tx_encoded).await?; - let tx_hash = format!("{:?}", tx.tx_hash()); - let link = make_remote_link(&format!("https://basescan.org/tx/{tx_hash}"), &tx_hash); + info!("4. Toggle \"Custom data\""); + info!("5. Put in \"ETH value\": 0"); + info!( + "6. Paste the transaction data in \"Data (Hex encoded)\": 0x{}", + tx_data + ); + info!("7. \"Add new transaction\" -> \"Create Batch\" -> \"Simulate\""); + info!("8. If simulation passes, \"Send Batch\""); + info!("9. Collect signatures from other Safe owners"); info!( - "{} {name} tx sent: {link}", - if *unpublish { "unpublish" } else { "publish" } + "10. Execute once threshold is reached (transaction only goes live in this final step)" ); + } else { + // Traditional wallet signing flow + let wallet = wallet.unwrap(); + let tx_envelope = tx.build(&wallet).await?; + let tx_encoded = tx_envelope.encoded_2718(); + if *mock { + info!( + "{} {name} tx mock successful", + if *unpublish { "unpublish" } else { "publish" } + ); + } else { + let tx = provider.send_raw_transaction(&tx_encoded).await?; + let tx_hash = format!("{:?}", tx.tx_hash()); + let link = make_remote_link(&format!("https://basescan.org/tx/{tx_hash}"), &tx_hash); + info!( + "{} {name} tx sent: {link}", + if *unpublish { "unpublish" } else { "publish" } + ); + } } Ok(()) } diff --git a/src/run_tests/mod.rs b/src/run_tests/mod.rs index fbc6afca..6e86b664 100644 --- a/src/run_tests/mod.rs +++ b/src/run_tests/mod.rs @@ -377,6 +377,7 @@ async fn build_packages( dependency_package_paths.clone(), vec![], // TODO false, + test.hyperapp.unwrap_or_default(), false, false, false, @@ -402,6 +403,7 @@ async fn build_packages( dependency_package_paths.clone(), vec![], // TODO false, + test.hyperapp.unwrap_or_default(), false, false, false, @@ -424,6 +426,7 @@ async fn build_packages( dependency_package_paths.clone(), vec![], // TODO false, + test.hyperapp.unwrap_or_default(), false, false, false, diff --git a/src/run_tests/types.rs b/src/run_tests/types.rs index 1735155e..b129fb17 100644 --- a/src/run_tests/types.rs +++ b/src/run_tests/types.rs @@ -32,6 +32,7 @@ pub struct Test { pub timeout_secs: u64, pub fakechain_router: u16, pub nodes: Vec, + pub hyperapp: Option, } #[derive(Debug, Clone, Serialize, Deserialize)] diff --git a/src/setup/mod.rs b/src/setup/mod.rs index 0cf66ab4..16cba2a0 100644 --- a/src/setup/mod.rs +++ b/src/setup/mod.rs @@ -344,34 +344,9 @@ pub fn check_foundry_deps() -> Result> { if !is_command_installed("anvil")? { return Ok(vec![Dependency::Foundry]); } - // let (_, installed_datetime) = get_foundry_version()?; Ok(vec![]) } -#[instrument(level = "trace", skip_all)] -fn get_foundry_version() -> Result<(String, String)> { - let output = run_command(Command::new("bash").args(&["-c", "anvil --version"]), false)?; - let Some(output) = output else { - return Err(eyre!( - "failed to fetch foundry version: anvil --version failed" - )); - }; - let output: Vec<&str> = output.0.split('(').nth(1).unwrap().split(' ').collect(); - if output.len() != 2 { - return Err(eyre!( - "failed to fetch foundry version: unexpected output: {output:?}" - )); - } - Ok(( - output[0].trim().to_string(), - output[1] - .trim() - .strip_suffix(')') - .unwrap_or_else(|| output[1]) - .to_string(), - )) -} - /// install forge+anvil+others, could be separated into binary extractions from github releases. #[instrument(level = "trace", skip_all)] fn install_foundry(verbose: bool) -> Result<()> { diff --git a/src/start_package/mod.rs b/src/start_package/mod.rs index ead1192e..c06e3503 100644 --- a/src/start_package/mod.rs +++ b/src/start_package/mod.rs @@ -236,7 +236,8 @@ pub async fn execute(package_dir: &Path, url: &str) -> Result<()> { .map_err(|e| { let e_string = e.to_string(); if e_string.contains("Failed with status code:") { - eyre!("{}\ncheck logs (default at {}) for full http response\n\nhint: is Kinode running at url {}?", e_string, KIT_LOG_PATH_DEFAULT, url) + eyre!("{e_string}\ncheck logs (default at {KIT_LOG_PATH_DEFAULT}) for full http response") + .with_suggestion(|| "is Hyperdrive running with `--expose-local` at url {url}?") } else { eyre!(e_string) }