Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 4 additions & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,10 @@ url = "2.5"

[dev-dependencies]
assert_cmd = "2.0"
# Real sha256 digests for the in-process OCI registry stub
# (`tests/harness/oci_stub.rs`): `oci_client` verifies every blob against
# its descriptor, so the stub cannot fake them.
sha2 = "0.10"

[lib]
name = "trix"
Expand Down
6 changes: 5 additions & 1 deletion src/commands/codegen.rs
Original file line number Diff line number Diff line change
Expand Up @@ -298,10 +298,14 @@ pub async fn run(
let templates_dir =
extract_github_templates(&github_url, &template_temp, &plugin.path).await?;

// Plugin defaults merged with the entry's `options`, user values
// winning — the same options for every protocol in this entry.
let options = codegen.resolved_options();

for (name, tii_path) in &targets {
let dest = base_output_dir.join(name);
std::fs::create_dir_all(&dest).into_diagnostic()?;
crate::spawn::tx3c::codegen(tii_path, &templates_dir, &dest)?;
crate::spawn::tx3c::codegen(tii_path, &templates_dir, &dest, &options)?;
println!("Bindgen successful for '{}'", name);
}
}
Expand Down
140 changes: 139 additions & 1 deletion src/config/convention.rs
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
use std::{
collections::{HashMap, HashSet},
collections::{BTreeMap, HashMap, HashSet},
path::PathBuf,
};

Expand Down Expand Up @@ -317,6 +317,41 @@ impl std::fmt::Display for KnownCodegenPlugin {
// `bindgen-v1alpha2` ref went with the now-removed legacy in-process codegen.)
const CURRENT_CODEGEN_VERSION: &str = "codegen-v1beta0";

/// Template options every built-in plugin gets unless the project says
/// otherwise.
///
/// Plugin knowledge lives here, in trix's config layer: `tx3c codegen` is
/// generic — it forwards whatever options it is handed into the template
/// data and has no idea which plugin they came from. The one default today
/// is `ts-client`'s `standalone`, which drives the `package.json.hbs` /
/// `tsconfig.json.hbs` gate in the `codegen-v1beta0` templates: a generated
/// TS binding is a self-contained package by default, and a project
/// consuming the bindings from inside a host package opts out with
/// `options = { standalone = false }`.
impl KnownCodegenPlugin {
pub fn default_options(&self) -> BTreeMap<String, serde_json::Value> {
match self {
KnownCodegenPlugin::TsClient => {
BTreeMap::from([("standalone".to_string(), serde_json::Value::Bool(true))])
}
KnownCodegenPlugin::RustClient
| KnownCodegenPlugin::PythonClient
| KnownCodegenPlugin::GoClient => BTreeMap::new(),
}
}
}

impl CodegenPlugin {
/// Defaults for this entry's plugin. A custom plugin is unknown to trix,
/// so it contributes none — its options are whatever the project writes.
pub fn default_options(&self) -> BTreeMap<String, serde_json::Value> {
match self {
CodegenPlugin::Known(plugin) => plugin.default_options(),
CodegenPlugin::Custom(_) => BTreeMap::new(),
}
}
}

impl From<KnownCodegenPlugin> for CodegenPluginConfig {
fn from(plugin: KnownCodegenPlugin) -> Self {
match plugin {
Expand Down Expand Up @@ -372,6 +407,20 @@ impl CodegenConfig {
self.plugin.name()
}

/// The template options this entry sends to `tx3c codegen`: the
/// plugin's defaults with the project's `[[codegen]].options` merged on
/// top, key by key — the user always wins, including when they set a
/// default's key back to `false`.
///
/// Ordered (`BTreeMap`) so the JSON trix forwards is stable across runs.
pub fn resolved_options(&self) -> BTreeMap<String, serde_json::Value> {
let mut options = self.plugin.default_options();
for (key, value) in self.options.iter().flatten() {
options.insert(key.clone(), value.clone());
}
options
}

pub fn output_dir(&self) -> miette::Result<PathBuf> {
if let Some(explicit) = &self.output_dir {
return Ok(explicit.clone());
Expand Down Expand Up @@ -495,6 +544,95 @@ mod tests {
assert!(err.contains("ts-client") && err.contains("rust-client"));
}

fn codegen_entry(toml_src: &str) -> CodegenConfig {
let config: RootConfig = toml::from_str(toml_src).unwrap();
config.codegen.into_iter().next().expect("a [[codegen]]")
}

const PROJECT_HEAD: &str = r#"
[protocol]
name = "demo"
version = "0.0.0"
main = "main.tx3"

[ledger]
family = "cardano"
"#;

#[test]
fn ts_client_defaults_to_standalone() {
let entry = codegen_entry(&format!(
"{PROJECT_HEAD}\n[[codegen]]\nplugin = \"ts-client\"\n"
));
assert_eq!(
entry.resolved_options(),
BTreeMap::from([("standalone".to_string(), serde_json::Value::Bool(true))])
);
}

#[test]
fn explicit_options_win_over_plugin_defaults() {
let entry = codegen_entry(&format!(
"{PROJECT_HEAD}\n[[codegen]]\nplugin = \"ts-client\"\noptions = {{ standalone = false }}\n"
));
assert_eq!(
entry.resolved_options(),
BTreeMap::from([("standalone".to_string(), serde_json::Value::Bool(false))])
);
}

#[test]
fn explicit_options_merge_alongside_defaults() {
let entry = codegen_entry(&format!(
"{PROJECT_HEAD}\n[[codegen]]\nplugin = \"ts-client\"\noptions = {{ package_name = \"acme\" }}\n"
));
assert_eq!(
entry.resolved_options(),
BTreeMap::from([
("standalone".to_string(), serde_json::Value::Bool(true)),
(
"package_name".to_string(),
serde_json::Value::String("acme".to_string())
),
])
);
}

#[test]
fn plugins_without_defaults_resolve_to_user_options_only() {
for plugin in ["rust-client", "python-client", "go-client"] {
let bare = codegen_entry(&format!(
"{PROJECT_HEAD}\n[[codegen]]\nplugin = \"{plugin}\"\n"
));
assert!(
bare.resolved_options().is_empty(),
"{plugin} should contribute no defaults"
);

let with_options = codegen_entry(&format!(
"{PROJECT_HEAD}\n[[codegen]]\nplugin = \"{plugin}\"\noptions = {{ flavor = \"lean\" }}\n"
));
assert_eq!(
with_options.resolved_options(),
BTreeMap::from([(
"flavor".to_string(),
serde_json::Value::String("lean".to_string())
)])
);
}
}

#[test]
fn custom_plugin_contributes_no_defaults() {
let entry = codegen_entry(&format!(
"{PROJECT_HEAD}\n[[codegen]]\nplugin = {{ repo = \"acme/lib\", path = \".\" }}\noptions = {{ standalone = true }}\n"
));
assert_eq!(
entry.resolved_options(),
BTreeMap::from([("standalone".to_string(), serde_json::Value::Bool(true))])
);
}

#[test]
fn registry_url_prefers_explicit() {
let toml = r#"
Expand Down
24 changes: 22 additions & 2 deletions src/spawn/tx3c.rs
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
use std::{path::Path, process::Command};
use std::{collections::BTreeMap, path::Path, process::Command};

use miette::{Context as _, IntoDiagnostic as _, bail};
use serde::Deserialize;
Expand Down Expand Up @@ -74,13 +74,33 @@ pub fn build_tii(source: &Path, output: &Path, config: &RootConfig) -> miette::R
Ok(())
}

pub fn codegen(tii_path: &Path, templates: &Path, output: &Path) -> miette::Result<()> {
/// Render `templates` against `tii_path` into `output`.
///
/// `options` is the template-options channel: the merged
/// `[[codegen]].options` (see [`crate::config::CodegenConfig::resolved_options`]),
/// forwarded as one JSON object under `--options` and surfacing in the
/// templates as `options.*`. An empty map is omitted entirely, so a project
/// that asks for nothing produces exactly the argv trix sent before the
/// channel existed.
pub fn codegen(
tii_path: &Path,
templates: &Path,
output: &Path,
options: &BTreeMap<String, serde_json::Value>,
) -> miette::Result<()> {
let mut cmd = tx3c()?;

cmd.args(["codegen", "--tii", tii_path.to_str().unwrap()]);
cmd.args(["--template", templates.to_str().unwrap()]);
cmd.args(["--output", output.to_str().unwrap()]);

if !options.is_empty() {
let json = serde_json::to_string(options)
.into_diagnostic()
.context("serializing codegen options")?;
cmd.args(["--options", json.as_str()]);
}

let output = cmd
.status()
.into_diagnostic()
Expand Down
24 changes: 20 additions & 4 deletions tests/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ The three layers, innermost first:
| Layer | Where | Spawns | Asks |
|---|---|---|---|
| **Unit** | `src/**` `#[cfg(test)]` | nothing | Are the rules right? (ref grammar, `[interfaces]` validation, cache integrity, resolver, compat window) |
| **CLI** (`tests/cli.rs`) | `tests/cli/` | `trix` only | Is the binary wired to the rules? (arg parsing, exit codes, `init` filesystem behavior, fail-before-spawn error paths) |
| **CLI** (`tests/cli.rs`) | `tests/cli/` | `trix` only | Is the binary wired to the rules? (arg parsing, exit codes, `init` filesystem behavior, fail-before-spawn error paths, registry pulls against the in-process OCI stub) |
| **Contract** (`tests/contract.rs`) | `tests/contract/` | `trix` + a **fake** `tx3c` | Is trix's side of the tx3c contract right? (argv, paths, ordering, output interpretation, version gate) |

There is deliberately no fourth layer. A trix test that needs a real `tx3c`
Expand Down Expand Up @@ -51,6 +51,20 @@ If trix's spawn contract changes (new flag, new subcommand), update the
fake alongside `src/spawn/` — and expect the umbrella journeys to catch any
drift between the fake's pretense and the real tool at release time.

## The OCI registry stub

`tests/harness/oci_stub.rs` is an in-process OCI Distribution registry
serving the read side of an anonymous pull (`/v2/` probe, manifest, blobs)
on `127.0.0.1:<random>`, with real sha256 digests because `oci_client`
verifies every blob against its descriptor. A test builds a
`StubProtocolImage`, serves its routes, and points the project at it with
`TestContext::set_registry_url`. It records every request path, so a test
can assert *which* repository path the client addressed — the way the
lowercase-addressing regression is locked.

Pulls are trix's own code, not a helper binary, so registry tests belong to
the CLI layer and the suite stays offline.

## Running

```bash
Expand All @@ -71,14 +85,16 @@ Ask what the assertion is about:
extract it (see `interfaces::verify_cache_at` for the pattern).
- **Wiring** (does command X actually run rule Y, with which exit code and
message) → `tests/cli/`. One probe per chokepoint; don't re-enumerate the
rule's variants here.
rule's variants here. A pull-path assertion goes here too, against the
OCI stub.
- **The tx3c contract** (what trix passes, which artifact feeds which
subcommand, how output/failures are interpreted) → `tests/contract/`,
asserting on `ctx.tx3c_invocations()` and the fake's file outputs.
- **Real binaries composing** (devnet round-trips, real codegen output,
install flows) → not here; add a journey in the umbrella's `solution/e2e/`
(see its README and the `add-e2e-journey` skill).

Fixtures live in `tests/fixtures/` (`use-stub/` — a cached interface;
`codegen-template/` — a minimal codegen plugin). `tests/infra/` is
Fixtures live in `tests/fixtures/` (`use-stub/` — a cached interface, also
the layer payload the OCI stub serves; `codegen-template/` — a minimal
codegen plugin). `tests/infra/` is
unrelated observability tooling, not part of the suites.
2 changes: 2 additions & 0 deletions tests/cli.rs
Original file line number Diff line number Diff line change
Expand Up @@ -17,3 +17,5 @@ mod init;
mod interfaces;
#[path = "cli/refs.rs"]
mod refs;
#[path = "cli/registry.rs"]
mod registry;
27 changes: 27 additions & 0 deletions tests/cli/refs.rs
Original file line number Diff line number Diff line change
Expand Up @@ -56,3 +56,30 @@ fn scoped_commands_reject_invalid_interfaces_table() {
let result = ctx.run_trix(&["inspect", "tir", "--tx", "transfer"]);
assert_failure_mentioning(&result, "alias-only");
}

/// Regression (re-cut from PR #128): npm-style `@` version separators are
/// forbidden in protocol references. The canonical grammar puts the version
/// after `:` and `@` is not a valid identifier character, so `trix use`
/// rejects the reference at clap parse time — before any registry traffic,
/// which is why this belongs at the CLI layer and needs no stub.
#[test]
fn use_rejects_npm_style_at_version_separator() {
let ctx = TestContext::new();
assert_success(&ctx.run_trix(&["init", "--yes"]));

let result = ctx.run_trix(&["use", "acme/widget@0.1.0"]);
assert!(
!result.success(),
"npm-style '@' ref should be rejected, got success:\n{}",
result.stdout
);
let combined = result.combined();
assert!(
combined.contains("invalid"),
"error should call the reference invalid:\n{combined}"
);
assert!(
combined.contains("widget@0.1.0"),
"error should echo the offending reference:\n{combined}"
);
}
Loading
Loading