diff --git a/Cargo.lock b/Cargo.lock index 9ea9440..6c6873a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -539,33 +539,6 @@ dependencies = [ "unicode-ident", ] -[[package]] -name = "ciborium" -version = "0.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "42e69ffd6f0917f5c029256a24d0161db17cea3997d185db0d35926308770f0e" -dependencies = [ - "ciborium-io", - "ciborium-ll", - "serde", -] - -[[package]] -name = "ciborium-io" -version = "0.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "05afea1e0a06c9be33d539b876f1ce3692f4afea2cb41f740e7743225ed1c757" - -[[package]] -name = "ciborium-ll" -version = "0.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "57663b653d948a338bfb3eeba9bb2fd5fcfaecb9e199e87e1eda4d9e8b240fd9" -dependencies = [ - "ciborium-io", - "half", -] - [[package]] name = "cipher" version = "0.4.4" @@ -2888,9 +2861,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1db05f56d34358a8b1066f67cbb203ee3e7ed2ba674a6263a1d5ec6db2204323" dependencies = [ "memchr", - "miette", - "serde", - "serde_json", "thiserror 2.0.17", "ucd-trie", ] @@ -4474,6 +4444,7 @@ dependencies = [ "predicates", "prost", "reqwest", + "semver", "serde", "serde_json", "serde_with", @@ -4485,8 +4456,6 @@ dependencies = [ "toml", "tracing", "tracing-subscriber", - "tx3-lang", - "tx3-tir", "utxorpc", "zip", ] @@ -4497,35 +4466,6 @@ version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" -[[package]] -name = "tx3-lang" -version = "0.14.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "22ccd68861b3f9fe5dc2df8ba4b258cc9a29ec23c17da0e1583ab11149e4aaf4" -dependencies = [ - "ciborium", - "hex", - "miette", - "pest", - "pest_derive", - "serde", - "thiserror 2.0.17", - "trait-variant", - "tx3-tir", -] - -[[package]] -name = "tx3-tir" -version = "0.14.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ed1dfb5f68fde6e552fdb1877e309cec4b9a56469ba94068dabd026660b911c3" -dependencies = [ - "ciborium", - "hex", - "serde", - "thiserror 2.0.17", -] - [[package]] name = "typenum" version = "1.18.0" diff --git a/Cargo.toml b/Cargo.toml index 5170a55..2aa03a0 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -7,13 +7,9 @@ repository = "https://github.com/tx3-lang/trix" homepage = "https://github.com/tx3-lang/trix" [dependencies] -tx3-lang = "0.14.2" -# tx3-lang = { path = "../tx3/crates/tx3-lang" } -# tx3-lang = { git = "https://github.com/tx3-lang/tx3.git" } - -tx3-tir = "0.14.2" -# tx3-tir = { path = "../tx3/crates/tx3-tir" } -# tx3-tir = { git = "https://github.com/tx3-lang/tx3.git" } +# Low-level Tx3 language operations (parse/analyze/lower, TIR decode) are +# delegated to the `tx3c` binary via `spawn::tx3c`, so `trix` links no +# `tx3-*` crate and its version is decoupled from the toolchain's. utxorpc = "0.12.0" # utxorpc = { git = "https://github.com/utxorpc/rust-sdk.git" } @@ -33,6 +29,7 @@ toml = "0.8" anyhow = "1.0" miette = { version = "7.5.0", features = ["fancy"] } thiserror = "2.0.12" +semver = "1.0" inquire = "0.7.5" dirs = "6.0.0" serde_json = "1.0.140" diff --git a/design/004-toolchain-delegation.md b/design/004-toolchain-delegation.md new file mode 100644 index 0000000..d1e8681 --- /dev/null +++ b/design/004-toolchain-delegation.md @@ -0,0 +1,111 @@ +# External CLI Delegation: `trix` as a Driver + +## Status + +Accepted — 2026-05-19. + +## Context + +`trix` is the Tx3 package manager and workspace orchestrator. It does not +implement the operations it exposes; it coordinates a set of dependent tools: +the language compiler (`tx3c`), the local chain node (`dolos`), the wallet / +transaction client (`cshell`), and others over time. + +Each dependent tool evolves on its own cadence, and the internal APIs of +several are unstable by design. Binding `trix` to any of them as a linked +library couples their release cadences: a `trix` release would be pinned to a +specific implementation version, and an unrelated change in that tool would +risk `trix` churn. `trix` and its dependents must move independently. + +## Decision + +**`trix` links no implementation crate of a dependent tool. It interacts with +each tool only by invoking its binary as a subprocess. A tool's command-line +surface — its subcommands, flags, and structured (JSON) I/O — is the contract +between `trix` and that tool.** + +This is the driver pattern, as `cargo` uses with `rustc`: the stable, +user-facing verbs live in the driver (`trix`); each capability lives behind a +small, flag-driven tool the driver shells out to. The toolchain manager +(`tx3up`) installs and pairs compatible versions, as `rustup` does for the +`cargo`/`rustc` pair. + +### Division of responsibility + +Each tool owns its domain; `trix` owns orchestration and presentation — +selecting what to run, supplying project/profile context, and rendering +results from each tool's structured output. No domain logic of a dependent +tool is reimplemented in `trix`. + +The language compiler is the primary instance: `trix` delegates all low-level +Tx3 operations (parse, analyze, lower, decode an artifact) to `tx3c` through +its CLI, reconstructing diagnostics and IR presentation from `tx3c`'s JSON +output. `dolos` (devnet) and `cshell` (wallets, submission) are delegated the +same way. The mechanism is uniform; the tools differ only in domain. + +## The contract and its versioning + +Because `trix` shares no types with a dependent tool, that tool's CLI is an +interface `trix` must version. The principles are tool-agnostic: + +1. **No in-band schema markers.** Versioning belongs to the surface, not to + each payload. Stamping every message with a schema tag versions one + payload, not the contract, and bloats the wire. + +2. **Gate on the binary version, against a per-tool window.** Compatibility + for every dependent tool lives in one matrix (`trix`'s `spawn::compat`): + an inclusive lower bound (the oldest release whose surface `trix` relies + on) and an exclusive upper bound at the **next major version**. A breaking + change to a tool's CLI is expected to be signalled by a major version bump + (semver); `trix` therefore accepts any release within the same major and + needs updating only when a tool makes a breaking, major change — not on + every minor. `spawn::ensure_supported(tool)` probes ` --version`, + range-checks, caches per process, and fails with a distinct, actionable + message per direction (too old → update the toolchain via `tx3up`; too new + → update `trix`). + +3. **Escape hatch for unreleased toolchains.** A locally built tool carries + the new surface but still reports its pre-release version. An environment + override bypasses the window for development and CI against an unreleased + toolchain; it is not for end users. + +Structured payloads are objects, not bare arrays, so they stay extensible: +additive fields are backward-compatible and need no version change; only +breaking changes do, paired with a tool major bump and a matrix update. + +## Consequences + +**Positive** + +- `trix`'s version is decoupled from every dependent tool's. Each ships on its + own cadence; `trix` follows only to adopt a new capability or a major break. +- The integration surface is small, explicit, and testable from outside. +- Implementation-internal API instability cannot leak into `trix`. +- One place (`spawn::compat`) describes every external-tool compatibility + requirement. + +**Costs** + +- A process boundary per operation. Acceptable: operations are coarse-grained, + so spawn cost is negligible against the work, and `trix` already spawned + these tools. +- Each tool's CLI / structured I/O is a real interface with real discipline: + a breaking change requires a major version bump on that tool and a + matrix-window update, coordinated across repos. +- Release sequencing: a tool release satisfying `trix`'s window must be + published and resolvable (via the toolchain dir / `tx3up`) before the + `trix` that requires it. + +## Alternatives considered + +- **Link a dependent tool's crates, track versions tightly.** The coupling + this decision exists to avoid; it keeps that tool's API instability inside + `trix`. +- **Link only for a subset of operations.** A partial dependency still pins + `trix` to a crate version and complicates the build; the marginal + in-process speedup is irrelevant for coarse operations. +- **In-band payload version markers.** Brittle; superseded by binary-version + windowing. +- **An exclusive upper bound at the next minor.** Rejected: it forces a `trix` + update for every dependent-tool minor even when nothing breaks. The next + *major* is the semver-correct breaking-change signal. diff --git a/src/commands/check.rs b/src/commands/check.rs index 76222cc..6e88af1 100644 --- a/src/commands/check.rs +++ b/src/commands/check.rs @@ -1,33 +1,49 @@ use crate::config::{ProfileConfig, RootConfig}; +use crate::spawn::tx3c; use clap::Args as ClapArgs; use miette::Diagnostic; -use miette::IntoDiagnostic as _; use thiserror::Error; +/// A single analyzer diagnostic, reconstructed from `tx3c`'s JSON contract. +/// `trix` owns the rendering (message + diagnostic code), so the human output +/// is unchanged even though the analysis now runs out-of-process. +#[derive(Debug, Error)] +#[error("{message}")] +struct Diag { + message: String, + code: Option, +} + +impl Diagnostic for Diag { + fn code<'a>(&'a self) -> Option> { + self.code + .as_ref() + .map(|c| Box::new(c.clone()) as Box) + } +} + #[derive(Debug, Error, Diagnostic)] #[error("check failed")] struct Error { #[related] - results: Vec, + results: Vec, } #[derive(ClapArgs, Debug)] pub struct Args {} pub fn run(_args: Args, config: &RootConfig, _profile: &ProfileConfig) -> miette::Result<()> { - let main_path = config.protocol.main.clone(); - - let content = std::fs::read_to_string(main_path).into_diagnostic()?; - - let mut program = tx3_lang::parsing::parse_string(&content)?; - - let diagnostic = tx3_lang::analyzing::analyze(&mut program); - - if !diagnostic.errors.is_empty() { - return Err(Error { - results: diagnostic.errors, - } - .into()); + let diagnostics = tx3c::check(&config.protocol.main)?; + + if !diagnostics.is_empty() { + let results = diagnostics + .into_iter() + .map(|d| Diag { + message: d.message, + code: d.code, + }) + .collect(); + return Err(Error { results }.into()); } println!("check passed, no errors found"); diff --git a/src/commands/codegen_legacy.rs b/src/commands/codegen_legacy.rs deleted file mode 100644 index c429599..0000000 --- a/src/commands/codegen_legacy.rs +++ /dev/null @@ -1,506 +0,0 @@ -use std::io::Read; -use std::{collections::HashMap, path::PathBuf}; - -use crate::config::{CodegenPluginConfig, ProfileConfig, RootConfig}; -use clap::Args as ClapArgs; -use miette::IntoDiagnostic; -use serde::{Serialize, Serializer}; - -use convert_case::{Case, Casing}; -use handlebars::{Context, Handlebars, Helper, Output, RenderContext, RenderErrorReason}; -use reqwest::Client; -use tempfile::TempDir; -use tx3_lang::Workspace; -use zip::ZipArchive; - -use tx3_tir::model::core::Type as TirType; - -#[derive(ClapArgs, Debug)] -pub struct Args {} - -/// Structure returned by load_github_templates containing handlebars and optional config -struct TemplateBundle { - handlebars: Handlebars<'static>, - static_files: Vec<(String, String)>, -} - -fn make_helper(name: &'static str, f: F) -> impl handlebars::HelperDef + Send + Sync + 'static -where - F: Fn(&str) -> String + Send + Sync + 'static, -{ - move |h: &Helper, _: &Handlebars, _: &Context, _: &mut RenderContext, out: &mut dyn Output| { - let param = h - .param(0) - .ok_or_else(|| RenderErrorReason::ParamNotFoundForIndex(name, 0))?; - let input = param - .value() - .as_str() - .ok_or_else(|| RenderErrorReason::InvalidParamType("Expected a string"))?; - out.write(&f(input))?; - Ok(()) - } -} - -fn parse_type_from_string(type_str: &str) -> Result { - match type_str { - "Int" => Ok(TirType::Int), - "Bool" => Ok(TirType::Bool), - "Bytes" => Ok(TirType::Bytes), - "Unit" => Ok(TirType::Unit), - "Address" => Ok(TirType::Address), - "UtxoRef" => Ok(TirType::UtxoRef), - "AnyAsset" => Ok(TirType::AnyAsset), - "Utxo" => Ok(TirType::Utxo), - "Undefined" => Ok(TirType::Undefined), - "List" => Ok(TirType::List), - x => Ok(TirType::Custom(x.to_string())), - } -} - -fn get_type_for_language(type_: &TirType, language: &str) -> String { - match language { - "rust" => "ArgValue".to_string(), - "typescript" => match &type_ { - TirType::Int => "bigint | number".to_string(), - TirType::Bool => "boolean".to_string(), - TirType::Bytes => "Uint8Array".to_string(), - TirType::Unit => "void".to_string(), - TirType::Address => "string".to_string(), - TirType::UtxoRef => "string".to_string(), - TirType::List => "any[]".to_string(), - TirType::Custom(name) => name.clone(), - TirType::AnyAsset => "string".to_string(), - TirType::Utxo => "any".to_string(), - TirType::Undefined => "any".to_string(), - TirType::Map => "any".to_string(), - }, - "python" => match &type_ { - TirType::Int => "int".to_string(), - TirType::Bool => "bool".to_string(), - TirType::Bytes => "bytes".to_string(), - TirType::Unit => "None".to_string(), - TirType::List => "list[Any]".to_string(), - TirType::Address => "str".to_string(), - TirType::UtxoRef => "str".to_string(), - TirType::Custom(name) => name.clone(), - TirType::AnyAsset => "str".to_string(), - TirType::Undefined => "Any".to_string(), - TirType::Utxo => "Any".to_string(), - TirType::Map => "Any".to_string(), - }, - "go" => match &type_ { - TirType::Int => "int64".to_string(), - TirType::Bool => "bool".to_string(), - TirType::Bytes => "[]byte".to_string(), - TirType::Unit => "struct{}".to_string(), - TirType::Address => "string".to_string(), - TirType::UtxoRef => "string".to_string(), - TirType::List => "[]interface{}".to_string(), - TirType::Custom(name) => name.clone(), - TirType::AnyAsset => "string".to_string(), - TirType::Utxo => "interface{}".to_string(), - TirType::Undefined => "interface{}".to_string(), - TirType::Map => "interface{}".to_string(), - }, - _ => "ArgValue".to_string(), // Default fallback - } -} - -// Register any custom helpers here -/// An array of helper functions for converting strings to various case styles. -/// -/// Each tuple in the array consists of: -/// - A string slice representing the name of the case style (e.g., "pascalCase"). -/// - A function pointer that takes a string slice and returns a `String` converted to the corresponding case style. -/// -/// These helpers are useful for dynamically applying different case transformations to strings, -/// such as converting identifiers to PascalCase, camelCase, CONSTANT_CASE, snake_case, or lower case. -fn register_handlebars_helpers(handlebars: &mut Handlebars<'_>) { - #[allow(clippy::type_complexity)] - let helpers: &[(&str, fn(&str) -> String)] = &[ - ("pascalCase", |s| s.to_case(Case::Pascal)), - ("camelCase", |s| s.to_case(Case::Camel)), - ("constantCase", |s| s.to_case(Case::Constant)), - ("snakeCase", |s| s.to_case(Case::Snake)), - ("lowerCase", |s| s.to_case(Case::Lower)), - ]; - - for (name, func) in helpers { - handlebars.register_helper(name, Box::new(make_helper(name, func))); - } - // Add more helpers as needed - - // Register helper to convert ir types to language types. - handlebars.register_helper( - "typeFor", - Box::new( - |h: &Helper, - _: &Handlebars, - _: &Context, - _: &mut RenderContext, - out: &mut dyn Output| { - let type_param = h - .param(0) - .ok_or_else(|| RenderErrorReason::ParamNotFoundForIndex("typeFor", 0))?; - let lang_param = h - .param(1) - .ok_or_else(|| RenderErrorReason::ParamNotFoundForIndex("typeFor", 1))?; - - let type_str = type_param.value().as_str().ok_or_else(|| { - RenderErrorReason::InvalidParamType("Expected type as string") - })?; - - let type_ = parse_type_from_string(type_str) - .map_err(|_| RenderErrorReason::InvalidParamType("Failed to parse type"))?; - - let language = lang_param.value().as_str().ok_or_else(|| { - RenderErrorReason::InvalidParamType("Expected language as string") - })?; - - let output_type = get_type_for_language(&type_, language); - - out.write(&output_type)?; - Ok(()) - }, - ), - ); -} - -/// Loads Handlebars templates from a GitHub repository ZIP archive. -/// -/// This function: -/// 1. Parses the GitHub URL in the format 'owner/repo' or 'owner/repo/branch' -/// 2. Downloads the repository as a ZIP file from GitHub -/// 3. Extracts the ZIP to a temporary directory -/// 4. Finds all `.hbs` files inside any `bindgen` directory in the archive -/// 5. Optionally loads a `trix-bindgen.toml` file from the `bindgen` directory -/// 6. Registers each found template with Handlebars, using its path relative to `bindgen/` (without the `.hbs` extension) -/// -/// Returns a TemplateBundle containing the Handlebars registry and optional configuration. -async fn load_github_templates( - github_url: &str, - temp_dir: &TempDir, - path: &str, -) -> miette::Result { - // Parse GitHub URL - let parts: Vec<&str> = github_url.split('/').collect(); - if parts.len() < 2 { - return Err(miette::miette!( - "Invalid GitHub URL format. Use 'owner/repo' or 'owner/repo/branch'" - )); - } - - let owner = parts[0]; - let repo = parts[1]; - let branch = if parts.len() > 2 { parts[2] } else { "main" }; - - // Create a zip download URL - let zip_url = format!( - "https://github.com/{}/{}/archive/{}.zip", - owner, repo, branch - ); - - println!( - "Reading template from https://github.com/{}/{} (ref: {})", - owner, repo, branch - ); - - // Download the zip file - let client = Client::new(); - let response = client.get(&zip_url).send().await.into_diagnostic()?; - - if !response.status().is_success() { - return Err(miette::miette!( - "Failed to download GitHub repository: HTTP {}", - response.status() - )); - } - - let zip_path = temp_dir.path().join("bindgen-template.zip"); - - // Save the zip file - let content = response.bytes().await.into_diagnostic()?; - std::fs::write(&zip_path, &content).into_diagnostic()?; - - // Extract the zip file - let file = std::fs::File::open(&zip_path).into_diagnostic()?; - let mut archive = ZipArchive::new(file).into_diagnostic()?; - - let mut bindgen_path = PathBuf::new(); - - // Get root_dir - let root_dir_name = archive.name_for_index(0).unwrap_or(""); - - bindgen_path.push(root_dir_name); - bindgen_path.push(path); - // Ensure the bindgen path ends with a separator - bindgen_path.push(""); - - // let mut config: Option = None; - // Check for trix-bindgen.toml in the directory - // let toml_name = bindgen_path.join("trix-bindgen.toml").to_string_lossy().to_string(); - - // if let Ok(mut config_file) = archive.by_name(&toml_name) { - // let mut config_content = String::new(); - // config_file.read_to_string(&mut config_content).into_diagnostic()?; - - // config = toml::from_str::(&config_content) - // .into_diagnostic() - // .ok(); - // } - - // Register handlebars templates - let mut handlebars = Handlebars::new(); - let mut static_files = Vec::new(); - - let bindgen_path_string = bindgen_path.to_string_lossy().to_string(); - let archive_bindgen_index = archive.index_for_name(&bindgen_path_string).unwrap_or(0); - - // Skip files that are not in the bindgen_path or are the bindgen_path itself - for i in archive_bindgen_index..archive.len() { - let mut file = archive.by_index(i).into_diagnostic()?; - let name = file.name().to_owned(); - - if !name.starts_with(&bindgen_path_string) { - break; // Stop processing if we reach a file outside the bindgen path - } - - // If the file is a directory or its the trix-bindgen.toml, skip it - if file.is_dir() || name.ends_with("trix-bindgen.toml") { - continue; - } - - // Remove everything before "bindgen/" and strip ".hbs" extension - let template_name = name.strip_prefix(&bindgen_path_string).unwrap_or(&name); - - if name.ends_with(".hbs") { - let template_name = template_name.strip_suffix(".hbs").unwrap_or(&name); - - let mut template_content = String::new(); - file.read_to_string(&mut template_content) - .into_diagnostic()?; - - // Register handlebars template - handlebars - .register_template_string(template_name, template_content) - .into_diagnostic()?; - - // println!("Registered template: {}", template_name); - continue; - } - - if file.is_file() { - let dest_path = temp_dir.path().join(template_name); - if let Some(parent) = dest_path.parent() { - std::fs::create_dir_all(parent).into_diagnostic()?; - } - let mut out_file = std::fs::File::create(&dest_path).into_diagnostic()?; - std::io::copy(&mut file, &mut out_file).into_diagnostic()?; - static_files.push((dest_path.display().to_string(), template_name.to_string())); - } - } - - register_handlebars_helpers(&mut handlebars); - - Ok(TemplateBundle { - handlebars, - static_files, - }) -} - -struct BytesHex(Vec); - -impl Serialize for BytesHex { - fn serialize(&self, serializer: S) -> Result - where - S: Serializer, - { - serializer.serialize_str(&hex::encode(&self.0)) - } -} - -#[derive(Serialize)] -struct TxParameter { - name: String, - type_name: tx3_tir::model::core::Type, -} - -#[derive(Serialize)] -struct Transaction { - name: String, - params_name: String, - function_name: String, - constant_name: String, - ir_bytes: BytesHex, - ir_version: String, - parameters: Vec, -} - -#[derive(Serialize)] -#[serde(rename_all = "camelCase")] -struct HandlebarsData { - protocol_name: String, - protocol_version: String, - trp_endpoint: String, - transactions: Vec, - headers: HashMap, - env_vars: HashMap, - options: HashMap, -} - -struct Job<'a> { - name: String, - workspace: &'a Workspace, - dest_path: PathBuf, - trp_endpoint: String, - trp_headers: HashMap, - env_args: HashMap, - options: HashMap, -} - -fn generate_arguments(job: &Job, version: &str) -> miette::Result { - let ast = job - .workspace - .ast() - .ok_or(miette::miette!("No AST available in workspace"))?; - - let transactions = ast - .txs - .iter() - .map(|tx_def| { - let tx_name = tx_def.name.value.as_str(); - let tx_tir = job.workspace.tir(tx_def.name.value.as_str()).unwrap(); - - let parameters: Vec = tx3_tir::reduce::find_params(tx_tir) - .iter() - .map(|(key, type_)| TxParameter { - name: key.as_str().to_case(Case::Camel), - type_name: type_.clone(), - }) - .collect(); - - let (tx_bytes, version) = tx3_tir::encoding::to_bytes(tx_tir); - - Transaction { - name: tx_name.to_string(), - params_name: format!("{}Params", tx_name).to_case(Case::Camel), - function_name: format!("{}Tx", tx_name).to_case(Case::Camel), - constant_name: format!("{}Ir", tx_name).to_case(Case::Camel), - ir_bytes: BytesHex(tx_bytes), - ir_version: version.to_string(), - parameters, - } - }) - .collect(); - - let headers = job - .trp_headers - .iter() - .map(|(key, value)| (key.clone(), value.clone())) - .collect::>(); - - let env_vars = job - .env_args - .iter() - .map(|(key, value)| (key.clone(), value.clone())) - .collect::>(); - - Ok(HandlebarsData { - protocol_name: job.name.clone(), - protocol_version: version.to_string(), - trp_endpoint: job.trp_endpoint.clone(), - transactions, - headers, - env_vars, - options: job.options.clone(), - }) -} - -async fn execute_bindgen( - job: &Job<'_>, - template_config: &CodegenPluginConfig, - version: &str, -) -> miette::Result<()> { - // Create a temporary directory to extract files - let temp_dir = TempDir::new().into_diagnostic()?; - let github_url = format!( - "{}/{}", - &template_config.repo, - template_config.r#ref.as_deref().unwrap_or("main") - ); - - let template_bundle = - load_github_templates(&github_url, &temp_dir, &template_config.path).await?; - - // Create the destination directory if it doesn't exist - std::fs::create_dir_all(&job.dest_path).into_diagnostic()?; - - let handlebars_params = generate_arguments(job, version)?; - - let handlebars_template_iter = template_bundle.handlebars.get_templates().iter(); - - for (name, _) in handlebars_template_iter { - let template_content = template_bundle - .handlebars - .render(name, &handlebars_params) - .unwrap(); - if template_content.is_empty() { - // Skip empty templates - continue; - } - let output_path = job.dest_path.join(name); - if let Some(parent) = output_path.parent() { - // Create parent directories if they don't exist - std::fs::create_dir_all(parent).into_diagnostic()?; - } - std::fs::write(&output_path, template_content).into_diagnostic()?; - // println!("Generated file: {}", output_path.display()); - } - - // Copy static files to the destination directory - for (src_path, file_destination) in &template_bundle.static_files { - let dest_path = job.dest_path.join(file_destination); - if let Some(parent) = dest_path.parent() { - std::fs::create_dir_all(parent).into_diagnostic()?; - } - std::fs::copy(src_path, dest_path).into_diagnostic()?; - // println!("Copied static file: {}", dest_path.display()); - } - - Ok(()) -} - -pub async fn run(_args: Args, config: &RootConfig, profile: &ProfileConfig) -> miette::Result<()> { - let mut ws = Workspace::from_file(&config.protocol.main)?; - - ws.parse()?; - ws.analyze()?; - ws.lower()?; - - for codegen in config.codegen.iter() { - let output_dir = codegen.output_dir()?; - - std::fs::create_dir_all(&output_dir).into_diagnostic()?; - - let plugin = CodegenPluginConfig::from(codegen.plugin.clone()); - - let network = config.resolve_profile_network(profile.name.as_str())?; - - let trp_config = &network.trp; - - let job = Job { - name: config.protocol.name.clone(), - workspace: &ws, - dest_path: output_dir, - trp_endpoint: trp_config.url.clone(), - trp_headers: trp_config.headers.clone(), - env_args: HashMap::new(), - options: codegen.options.clone().unwrap_or_default(), - }; - - execute_bindgen(&job, &plugin, &config.protocol.version).await?; - println!("Bindgen successful"); - } - - Ok(()) -} diff --git a/src/commands/inspect/tir.rs b/src/commands/inspect/tir.rs index d6a32a3..7bed005 100644 --- a/src/commands/inspect/tir.rs +++ b/src/commands/inspect/tir.rs @@ -1,12 +1,10 @@ -use std::collections::HashMap; - use clap::Args as ClapArgs; -use miette::{Context as _, IntoDiagnostic as _}; -use serde::Deserialize; +use miette::IntoDiagnostic as _; use crate::config::RootConfig; use crate::interfaces::{self, ResolvedProtocol, Resolver}; use crate::refs::TxRef; +use crate::spawn::tx3c; #[derive(ClapArgs)] pub struct Args { @@ -21,34 +19,6 @@ pub struct Args { pretty: bool, } -// Minimal mirror of the parts of the published TII we need to recover a -// transaction's TIR. The cached `.tii` is the *normative* artifact; we never -// re-derive IR from the (informative) cached `.tx3` source. -#[derive(Deserialize)] -struct TiiLite { - transactions: HashMap, -} - -#[derive(Deserialize)] -struct TxLite { - tir: TirEnvelopeLite, -} - -#[derive(Deserialize)] -struct TirEnvelopeLite { - content: String, - #[serde(rename = "contentType", alias = "encoding")] - encoding: TirEncodingLite, - version: String, -} - -#[derive(Deserialize)] -#[serde(rename_all = "lowercase")] -enum TirEncodingLite { - Hex, - Base64, -} - pub fn run(args: Args, config: &RootConfig) -> miette::Result<()> { // `inspect tir` is a consuming command: it may target an interface, so it // applies the interface integrity gate up front, exactly like `invoke` @@ -60,21 +30,15 @@ pub fn run(args: Args, config: &RootConfig) -> miette::Result<()> { let (resolved, tx_name) = resolver.resolve_tx(&args.tx)?; let ir = match resolved { - // The author's own source is normative for the project. + // The author's own source is normative for the project; `tx3c` lowers + // it. An interface is consumed via its published TII, whose encoded + // TIR `tx3c` decodes. Both paths yield the same JSON shape, so the + // caller can't tell which protocol it came from. ResolvedProtocol::Project => { - let content = - std::fs::read_to_string(&config.protocol.main).into_diagnostic()?; - let mut ast = tx3_lang::parsing::parse_string(&content)?; - tx3_lang::analyzing::analyze(&mut ast).ok()?; - let lowered = tx3_lang::lowering::lower(&ast, tx_name) - .into_diagnostic() - .with_context(|| format!("lowering {}", tx_name))?; - serde_json::to_value(&lowered).into_diagnostic()? + tx3c::tir_from_source(&config.protocol.main, tx_name)? } - // An interface is consumed via its published TII; it carries the - // encoded TIR per transaction, so no source compilation is involved. ResolvedProtocol::Interface(entry) => { - tir_from_interface(interfaces::cache_paths(entry)?.tii, tx_name)? + tx3c::decode_tir(&interfaces::cache_paths(entry)?.tii, tx_name)? } }; @@ -86,43 +50,3 @@ pub fn run(args: Args, config: &RootConfig) -> miette::Result<()> { Ok(()) } - -/// Decode `tx_name`'s TIR straight out of an interface's cached, published -/// `.tii`. Returns the same JSON shape the project path emits (the inner -/// `v1beta0` tx), so callers can't tell which protocol it came from. -fn tir_from_interface( - tii_path: std::path::PathBuf, - tx_name: &str, -) -> miette::Result { - let bytes = std::fs::read(&tii_path).into_diagnostic()?; - let tii: TiiLite = serde_json::from_slice(&bytes) - .into_diagnostic() - .context("parsing cached TII")?; - - let envelope = tii - .transactions - .get(tx_name) - .map(|t| &t.tir) - .ok_or_else(|| miette::miette!("transaction '{}' not found in interface", tx_name))?; - - let raw = match envelope.encoding { - TirEncodingLite::Hex => hex::decode(&envelope.content) - .into_diagnostic() - .context("decoding hex TIR")?, - TirEncodingLite::Base64 => { - return Err(miette::miette!( - "interface TIR is base64-encoded, which this trix does not support; \ - ask the publisher to re-`trix publish`" - )); - } - }; - - let version = tx3_tir::encoding::TirVersion::try_from(envelope.version.as_str()) - .map_err(|e| miette::miette!("unsupported TIR version: {e}"))?; - - let any = tx3_tir::encoding::from_bytes(&raw, version) - .map_err(|e| miette::miette!("decoding TIR: {e}"))?; - - let tx3_tir::encoding::AnyTir::V1Beta0(tx) = any; - serde_json::to_value(&tx).into_diagnostic() -} diff --git a/src/commands/mod.rs b/src/commands/mod.rs index b39bfba..b843a73 100644 --- a/src/commands/mod.rs +++ b/src/commands/mod.rs @@ -1,5 +1,6 @@ pub mod build; pub mod check; +pub mod codegen; pub mod devnet; pub mod expect; pub mod explore; @@ -12,12 +13,3 @@ pub mod publish; pub mod telemetry; pub mod test; pub mod use_cmd; - -#[cfg(feature = "unstable")] -pub mod codegen; - -#[cfg(not(feature = "unstable"))] -pub mod codegen_legacy; - -#[cfg(not(feature = "unstable"))] -pub use codegen_legacy as codegen; diff --git a/src/config/convention.rs b/src/config/convention.rs index eb807d6..f0be172 100644 --- a/src/config/convention.rs +++ b/src/config/convention.rs @@ -233,10 +233,9 @@ impl std::fmt::Display for KnownCodegenPlugin { } } -#[cfg(not(feature = "unstable"))] -const CURRENT_CODEGEN_VERSION: &str = "bindgen-v1alpha2"; - -#[cfg(feature = "unstable")] +// Codegen is delegated entirely to `tx3c`; the built-in SDK plugins are +// pinned to the `codegen-v1beta0` bindgen templates that match it. (The old +// `bindgen-v1alpha2` ref went with the now-removed legacy in-process codegen.) const CURRENT_CODEGEN_VERSION: &str = "codegen-v1beta0"; impl From for CodegenPluginConfig { diff --git a/src/main.rs b/src/main.rs index dc3d972..c6f3c16 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,11 +1,10 @@ use clap::Parser; use trix::{ - builder, cli::{Cli, Commands}, commands as cmds, config::RootConfig, - devnet, dirs, global, home, spawn, telemetry, updates, wallet, + global, telemetry, updates, }; use miette::{IntoDiagnostic as _, Result}; diff --git a/src/spawn/compat.rs b/src/spawn/compat.rs new file mode 100644 index 0000000..9c0fcbf --- /dev/null +++ b/src/spawn/compat.rs @@ -0,0 +1,117 @@ +//! Compatibility matrix and version gating for the external CLIs `trix` +//! drives. +//! +//! `trix` links no implementation crate of a dependent tool; it orchestrates +//! the toolchain binaries (`tx3c`, `cshell`, `dolos`) as subprocesses. Each +//! tool's CLI surface — subcommands, flags, JSON output — is the versioned +//! contract. Rather than embed markers in payloads, we gate on the binary's +//! own `--version`. This module owns that mechanism and the single matrix for +//! every integration. See `design/004-toolchain-delegation.md`. + +use std::collections::HashMap; +use std::process::Command; +use std::sync::{Mutex, OnceLock}; + +/// The supported version window for one external CLI. `min` is the inclusive +/// lower bound — the oldest release whose surface `trix` relies on. The +/// exclusive upper bound is derived, not stored: it is the **next major** +/// version (`min.major + 1`.0.0). +/// +/// A breaking change to a tool's CLI is expected to be signalled by a major +/// version bump (semver), so `trix` accepts any release within the same major +/// and needs updating only when a tool makes a breaking, major change — not on +/// every minor. Raise `min` when `trix` starts relying on a newer capability. +struct Compat { + tool: &'static str, + min: &'static str, +} + +const COMPAT_MATRIX: &[Compat] = &[ + // 0.18.0 introduced `decode`, `--emit tir-json`, `--diagnostics-format` + // (0.17.0 was cut before that surface existed). + Compat { + tool: "tx3c", + min: "0.18.0", + }, +]; + +fn entry(tool: &str) -> Option<&'static Compat> { + COMPAT_MATRIX.iter().find(|c| c.tool == tool) +} + +/// Probe ` --version` and confirm it falls within the supported window +/// in [`COMPAT_MATRIX`] (`min <= v`, and `v` within the same major as `min`). +/// +/// A no-op for tools not in the matrix. Cached per tool: a toolchain can't +/// change mid-process, so each tool is probed at most once. Call this before +/// the first subprocess invocation of any gated tool. +/// +/// Escape hatch: setting `TX3_SKIP_COMPAT_CHECK` to a non-empty value bypasses +/// the window. This exists for developing/CI-testing against an *unreleased* +/// toolchain — a locally built tool carries the new CLI surface but still +/// reports the pre-bump version until its release is cut. Not for end users. +pub fn ensure_supported(tool: &str) -> miette::Result<()> { + if std::env::var_os("TX3_SKIP_COMPAT_CHECK").is_some_and(|v| !v.is_empty()) { + return Ok(()); + } + + let Some(c) = entry(tool) else { + return Ok(()); + }; + + static CACHE: OnceLock>>> = OnceLock::new(); + let cache = CACHE.get_or_init(|| Mutex::new(HashMap::new())); + + let cached = cache.lock().unwrap().get(c.tool).cloned(); + let result = match cached { + Some(r) => r, + None => { + let r = check(c); + cache.lock().unwrap().insert(c.tool, r.clone()); + r + } + }; + + result.map_err(|m| miette::miette!("incompatible tx3 toolchain: {m}")) +} + +fn check(c: &Compat) -> Result<(), String> { + let tool = c.tool; + let path = crate::home::tool_path(tool).map_err(|e| e.to_string())?; + + let output = Command::new(&path) + .arg("--version") + .output() + .map_err(|e| format!("could not run `{tool} --version`: {e}"))?; + + if !output.status.success() { + return Err(format!("`{tool} --version` exited with an error")); + } + + // clap-based tools print ` `. + let stdout = String::from_utf8_lossy(&output.stdout); + let raw = stdout.split_whitespace().last().unwrap_or("").trim(); + let found = semver::Version::parse(raw) + .map_err(|e| format!("cannot parse {tool} version from {stdout:?}: {e}"))?; + + let min = semver::Version::parse(c.min).expect("valid matrix const"); + // Exclusive upper bound: the next major. Same-major releases are accepted; + // a breaking CLI change must come with a major bump. + let before = semver::Version::new(min.major + 1, 0, 0); + + if found < min { + return Err(format!( + "your {tool} is {found}, but this trix requires {tool} >= {min}. \ + Run `tx3up` to update your tx3 toolchain." + )); + } + + if found >= before { + return Err(format!( + "your {tool} is {found}, newer than this trix supports \ + ({tool} >= {min}, < {before}). Update trix (or pin an older {tool})." + )); + } + + Ok(()) +} diff --git a/src/spawn/cshell.rs b/src/spawn/cshell.rs index 62261bf..4f98437 100644 --- a/src/spawn/cshell.rs +++ b/src/spawn/cshell.rs @@ -85,6 +85,8 @@ pub struct CshellTomlTemplate { } fn new_generic_command(home: &Path) -> miette::Result { + crate::spawn::ensure_supported("cshell")?; + let tool_path = crate::home::tool_path("cshell")?; let config_path = home.join("cshell.toml"); diff --git a/src/spawn/dolos.rs b/src/spawn/dolos.rs index 552595e..e7a86a9 100644 --- a/src/spawn/dolos.rs +++ b/src/spawn/dolos.rs @@ -56,6 +56,8 @@ pub fn initialize_config( } pub fn daemon(home: &Path, silent: bool) -> miette::Result { + crate::spawn::ensure_supported("dolos")?; + let tool_path = crate::home::tool_path("dolos")?; let config_path = home.join("dolos.toml"); diff --git a/src/spawn/mod.rs b/src/spawn/mod.rs index c244d86..92f6cd8 100644 --- a/src/spawn/mod.rs +++ b/src/spawn/mod.rs @@ -1,3 +1,13 @@ +//! Spawning the external CLIs `trix` orchestrates. +//! +//! `trix` links no `tx3-*` crate; it drives the toolchain binaries (`tx3c`, +//! `cshell`, `dolos`) as subprocesses. Version compatibility for every +//! integration lives in [`compat`]; each spawn path calls +//! [`ensure_supported`] at its command chokepoint before invoking the tool. + +pub mod compat; pub mod cshell; pub mod dolos; pub mod tx3c; + +pub use compat::ensure_supported; diff --git a/src/spawn/tx3c.rs b/src/spawn/tx3c.rs index 4c895ad..da9470d 100644 --- a/src/spawn/tx3c.rs +++ b/src/spawn/tx3c.rs @@ -1,13 +1,43 @@ use std::{path::Path, process::Command}; use miette::{bail, Context as _, IntoDiagnostic as _}; +use serde::Deserialize; use crate::config::RootConfig; +use crate::spawn::ensure_supported; + +/// One analyzer diagnostic, as emitted by `tx3c … --diagnostics-format json`. +/// Its shape is part of the `tx3c` CLI contract, gated by the compatibility +/// matrix in [`crate::spawn`]. +#[derive(Debug, Deserialize)] +pub struct Diagnostic { + pub severity: String, + #[serde(default)] + pub code: Option, + pub message: String, + #[serde(default)] + pub span: Option, +} -pub fn build_tii(source: &Path, output: &Path, config: &RootConfig) -> miette::Result<()> { +#[derive(Debug, Deserialize)] +pub struct DiagnosticSpan { + pub start: usize, + pub end: usize, +} + +#[derive(Debug, Deserialize)] +struct DiagnosticsEnvelope { + diagnostics: Vec, +} + +fn tx3c() -> miette::Result { + ensure_supported("tx3c")?; let tool_path = crate::home::tool_path("tx3c")?; + Ok(Command::new(tool_path.to_str().unwrap_or_default())) +} - let mut cmd = Command::new(tool_path.to_str().unwrap_or_default()); +pub fn build_tii(source: &Path, output: &Path, config: &RootConfig) -> miette::Result<()> { + let mut cmd = tx3c()?; cmd.args(["build", source.to_str().unwrap()]); cmd.args(["--emit", "tii"]); @@ -45,9 +75,7 @@ pub fn build_tii(source: &Path, output: &Path, config: &RootConfig) -> miette::R } pub fn codegen(tii_path: &Path, templates: &Path, output: &Path) -> miette::Result<()> { - let tool_path = crate::home::tool_path("tx3c")?; - - let mut cmd = Command::new(tool_path.to_str().unwrap_or_default()); + let mut cmd = tx3c()?; cmd.args(["codegen", "--tii", tii_path.to_str().unwrap()]); cmd.args(["--template", templates.to_str().unwrap()]); @@ -64,3 +92,76 @@ pub fn codegen(tii_path: &Path, templates: &Path, output: &Path) -> miette::Resu Ok(()) } + +/// Run the front end over `source` (parse + analyze, no lowering, no +/// artifact) and return the analyzer diagnostics. Empty ⇒ the check passed. +/// +/// `tx3c` exits non-zero when there are errors but still writes the envelope +/// to stdout, so a non-zero status is *not* a spawn failure here — we parse +/// stdout regardless and only treat an unparseable/empty stream as one. +pub fn check(source: &Path) -> miette::Result> { + let mut cmd = tx3c()?; + cmd.args(["build", source.to_str().unwrap()]); + cmd.args(["--diagnostics-format", "json"]); + + let output = cmd + .output() + .into_diagnostic() + .context("running tx3c check")?; + + let envelope: DiagnosticsEnvelope = serde_json::from_slice(&output.stdout) + .into_diagnostic() + .with_context(|| { + format!( + "parsing tx3c diagnostics (stderr: {})", + String::from_utf8_lossy(&output.stderr).trim() + ) + })?; + + Ok(envelope.diagnostics) +} + +/// Capture the stdout of a `tx3c` invocation that prints a single JSON value, +/// bailing with stderr on a non-zero exit. Used by the TIR-inspection paths. +fn capture_json(mut cmd: Command, what: &str) -> miette::Result { + let output = cmd + .output() + .into_diagnostic() + .with_context(|| format!("running tx3c {what}"))?; + + if !output.status.success() { + bail!( + "tx3c {what} failed: {}", + String::from_utf8_lossy(&output.stderr).trim() + ); + } + + serde_json::from_slice(&output.stdout) + .into_diagnostic() + .with_context(|| format!("parsing tx3c {what} output")) +} + +/// Lower `tx_name` from project `source` and return its v1beta0 TIR as JSON. +pub fn tir_from_source( + source: &Path, + tx_name: &str, +) -> miette::Result { + let mut cmd = tx3c()?; + cmd.args(["build", source.to_str().unwrap()]); + cmd.args(["--emit", "tir-json"]); + cmd.args(["--tx", tx_name]); + capture_json(cmd, "tir-json") +} + +/// Decode `tx_name`'s TIR out of a published `.tii`. Same JSON shape as +/// [`tir_from_source`], so callers can't tell source from artifact. +pub fn decode_tir( + tii_path: &Path, + tx_name: &str, +) -> miette::Result { + let mut cmd = tx3c()?; + cmd.args(["decode", "--tii", tii_path.to_str().unwrap()]); + cmd.args(["--emit", "tir-json"]); + cmd.args(["--tx", tx_name]); + capture_json(cmd, "decode") +} diff --git a/tests/e2e/codegen_deps.rs b/tests/e2e/codegen_deps.rs index 17597be..fa1d85b 100644 --- a/tests/e2e/codegen_deps.rs +++ b/tests/e2e/codegen_deps.rs @@ -1,8 +1,6 @@ -//! Interface-aware codegen lives only in the unstable codegen path -//! (`src/commands/codegen.rs`). These tests are compiled and run only under -//! `cargo test --features unstable`, where `assert_cmd::cargo_bin` resolves -//! the unstable-built `trix` binary. They require a real `tx3c` (like -//! `happy_path::codegen_generates_bindings_from_fixture`). +//! Interface-aware codegen (`src/commands/codegen.rs`), which delegates the +//! whole pipeline to the `tx3c` binary. These tests require a real `tx3c` +//! (like `happy_path::codegen_generates_bindings_from_fixture`). use super::*; use std::path::PathBuf; diff --git a/tests/e2e/happy_path.rs b/tests/e2e/happy_path.rs index 1c593ce..a003802 100644 --- a/tests/e2e/happy_path.rs +++ b/tests/e2e/happy_path.rs @@ -170,13 +170,23 @@ fn codegen_generates_bindings_from_fixture() { )); ctx.write_file("trix.toml", &trix_toml); + let project_name = ctx.load_trix_config().protocol.name; + let result = ctx.run_trix(&["codegen"]); assert_success(&result); - ctx.assert_file_exists("gen/bindings.txt"); - ctx.assert_file_contains("gen/bindings.txt", "Protocol:"); - ctx.assert_file_contains("gen/bindings.txt", "Transactions:"); - ctx.assert_file_contains("gen/bindings.txt", "transfer"); - ctx.assert_file_contains("gen/bindings.txt", "Profiles:"); - ctx.assert_file_contains("gen/bindings.txt", "local"); + // Output nests under the project's own subdir (the deliberate layout of + // the tx3c-delegating codegen, see `codegen_deps`); nothing is written + // flat at `gen/bindings.txt`. + let bindings = format!("gen/{project_name}/bindings.txt"); + ctx.assert_file_exists(&bindings); + ctx.assert_file_contains(&bindings, "Protocol:"); + ctx.assert_file_contains(&bindings, "Transactions:"); + ctx.assert_file_contains(&bindings, "transfer"); + ctx.assert_file_contains(&bindings, "Profiles:"); + ctx.assert_file_contains(&bindings, "local"); + assert!( + !ctx.file_path("gen/bindings.txt").exists(), + "unified layout: nothing should be written flat at gen/bindings.txt" + ); } diff --git a/tests/e2e/mod.rs b/tests/e2e/mod.rs index 997bc82..b4f8eeb 100644 --- a/tests/e2e/mod.rs +++ b/tests/e2e/mod.rs @@ -77,6 +77,10 @@ impl TestContext { "TX3_TX3C_PATH".to_string(), path.to_string_lossy().to_string(), )); + // The tx3c under test is built from this tree: it has the new CLI + // surface but still reports the pre-release version, which is + // outside trix's compat window. Bypass the gate for the suite. + envs.push(("TX3_SKIP_COMPAT_CHECK".to_string(), "1".to_string())); } envs @@ -268,7 +272,6 @@ pub fn is_process_running(_pid: u32) -> bool { true } -#[cfg(feature = "unstable")] pub mod codegen_deps; pub mod edge_cases; pub mod happy_path;