Skip to content
Merged
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
2 changes: 2 additions & 0 deletions src/commands/init.rs
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,7 @@ fn consumer_default_config() -> RootConfig {
ledger: LedgerConfig {
family: KnownLedgerFamily::Cardano,
},
toolchain: None,
codegen: Vec::new(),
profiles: NamedMap::default(),
networks: NamedMap::default(),
Expand Down Expand Up @@ -151,6 +152,7 @@ fn default_config() -> RootConfig {
ledger: LedgerConfig {
family: KnownLedgerFamily::Cardano,
},
toolchain: None,
codegen: Vec::new(),
profiles: NamedMap::default(),
networks: NamedMap::default(),
Expand Down
14 changes: 14 additions & 0 deletions src/config/model.rs
Original file line number Diff line number Diff line change
Expand Up @@ -326,12 +326,26 @@ impl Named for InterfaceEntry {
}
}

/// Declared minimum versions of the toolchain binaries `trix` drives, from the
/// `[toolchain]` table in `trix.toml`. These are *project* requirements (this
/// protocol needs at least version X); they raise — never lower — the built-in
/// support window `trix` enforces in [`crate::spawn::compat`].
#[derive(Debug, Serialize, Deserialize, Clone, Default)]
pub struct ToolchainConfig {
/// Minimum `tx3c` version this protocol requires (semver, e.g. "0.22.0").
#[serde(default, skip_serializing_if = "Option::is_none")]
pub tx3c: Option<String>,
}

#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct RootConfig {
pub protocol: ProtocolConfig,

pub ledger: LedgerConfig,

#[serde(default, skip_serializing_if = "Option::is_none")]
pub toolchain: Option<ToolchainConfig>,

#[serde(default)]
pub registry: Option<RegistryConfig>,

Expand Down
4 changes: 4 additions & 0 deletions src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,10 @@ fn run_global_command(cli: Cli) -> Result<()> {
}

async fn run_scoped_command(cli: Cli, config: RootConfig, config_path: PathBuf) -> Result<()> {
// Record this project's declared toolchain minimums before any command can
// spawn a tool, so version gating (spawn::compat) enforces them.
trix::spawn::compat::register_project_requirements(&config)?;

let profile = config.resolve_profile(&cli.profile)?;

let metric = telemetry::track_command_execution(&cli);
Expand Down
188 changes: 168 additions & 20 deletions src/spawn/compat.rs
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@ use std::collections::HashMap;
use std::process::Command;
use std::sync::{Mutex, OnceLock};

use crate::config::RootConfig;

/// 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**
Expand Down Expand Up @@ -39,6 +41,45 @@ fn entry(tool: &str) -> Option<&'static Compat> {
COMPAT_MATRIX.iter().find(|c| c.tool == tool)
}

/// Per-tool minimum versions declared by the current project's `trix.toml`
/// `[toolchain]` table. Set once at command startup (a process drives a single
/// project), read during version gating.
static PROJECT_MINS: OnceLock<HashMap<String, semver::Version>> = OnceLock::new();

/// Record the project-declared minimum tool versions from `config`. Call once,
/// before the first tool spawn. Version strings are parsed here so a malformed
/// `[toolchain]` entry in `trix.toml` fails fast with a clear error.
///
/// A project minimum is a *lower bound only*: it raises the floor of the
/// built-in support window ([`COMPAT_MATRIX`]) but never relaxes its upper
/// bound. A tool with no matrix entry is still gated against its project
/// minimum, if one is declared.
pub fn register_project_requirements(config: &RootConfig) -> miette::Result<()> {
let mins = collect_project_mins(config)?;
// OnceLock: first writer wins; a process only ever loads one project.
let _ = PROJECT_MINS.set(mins);
Ok(())
}

/// Parse the declared `[toolchain]` minimums into a tool→version map, failing
/// on a malformed version string. Pure (no global state) so it is unit-testable.
fn collect_project_mins(config: &RootConfig) -> miette::Result<HashMap<String, semver::Version>> {
let mut mins = HashMap::new();

if let Some(req) = config.toolchain.as_ref().and_then(|t| t.tx3c.as_ref()) {
let version = semver::Version::parse(req).map_err(|e| {
miette::miette!("invalid `[toolchain] tx3c` version {req:?} in trix.toml: {e}")
})?;
mins.insert("tx3c".to_string(), version);
}

Ok(mins)
}

fn project_min(tool: &str) -> Option<semver::Version> {
PROJECT_MINS.get().and_then(|m| m.get(tool).cloned())
}

/// Probe `<tool> --version` and confirm it falls within the supported window
/// in [`COMPAT_MATRIX`] (`min <= v`, and `v` within the same major as `min`).
///
Expand All @@ -55,28 +96,36 @@ pub fn ensure_supported(tool: &str) -> miette::Result<()> {
return Ok(());
}

let Some(c) = entry(tool) else {
let matrix = entry(tool);
let project_min = project_min(tool);

// Nothing to enforce: tool is neither in the matrix nor constrained by the
// project's `trix.toml`.
if matrix.is_none() && project_min.is_none() {
return Ok(());
};
}

static CACHE: OnceLock<Mutex<HashMap<&'static str, Result<(), String>>>> = OnceLock::new();
static CACHE: OnceLock<Mutex<HashMap<String, Result<(), String>>>> = OnceLock::new();
let cache = CACHE.get_or_init(|| Mutex::new(HashMap::new()));

let cached = cache.lock().unwrap().get(c.tool).cloned();
let cached = cache.lock().unwrap().get(tool).cloned();
let result = match cached {
Some(r) => r,
None => {
let r = check(c);
cache.lock().unwrap().insert(c.tool, r.clone());
let r = check(tool, matrix, project_min.as_ref());
cache.lock().unwrap().insert(tool.to_string(), r.clone());
r
}
};

result.map_err(|m| miette::miette!("incompatible tx3 toolchain: {m}"))
}

fn check(c: &Compat) -> Result<(), String> {
let tool = c.tool;
fn check(
tool: &str,
matrix: Option<&Compat>,
project_min: Option<&semver::Version>,
) -> Result<(), String> {
let path = crate::home::tool_path(tool).map_err(|e| e.to_string())?;

let output = Command::new(&path)
Expand All @@ -94,24 +143,123 @@ fn check(c: &Compat) -> Result<(), String> {
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);
evaluate(tool, &found, matrix, project_min)
}

if found < min {
/// Decide whether `found` satisfies the project floor and trix's support
/// window. Pure (no subprocess, no globals) so the version logic is
/// unit-testable in isolation from the `--version` probe.
fn evaluate(
tool: &str,
found: &semver::Version,
matrix: Option<&Compat>,
project_min: Option<&semver::Version>,
) -> Result<(), String> {
// Project-declared floor (from `trix.toml [toolchain]`). Lower bound only.
if let Some(min) = project_min.filter(|min| found < *min) {
return Err(format!(
"your {tool} is {found}, but this trix requires {tool} >= {min}. \
Run `tx3up` to update your tx3 toolchain."
"your {tool} is {found}, but this protocol requires {tool} >= {min} \
(declared in trix.toml [toolchain]). 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})."
));
// trix's own built-in support window.
if let Some(c) = matrix {
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(())
}

#[cfg(test)]
mod tests {
use super::*;

fn v(s: &str) -> semver::Version {
semver::Version::parse(s).unwrap()
}

const MATRIX: Compat = Compat {
tool: "tx3c",
min: "0.18.0",
};

#[test]
fn project_min_is_a_lower_bound() {
assert!(evaluate("tx3c", &v("0.21.0"), None, Some(&v("0.22.0"))).is_err());
assert!(evaluate("tx3c", &v("0.22.0"), None, Some(&v("0.22.0"))).is_ok());
assert!(evaluate("tx3c", &v("0.25.0"), None, Some(&v("0.22.0"))).is_ok());
}

#[test]
fn matrix_window_enforced() {
assert!(evaluate("tx3c", &v("0.17.0"), Some(&MATRIX), None).is_err()); // below floor
assert!(evaluate("tx3c", &v("0.18.0"), Some(&MATRIX), None).is_ok());
assert!(evaluate("tx3c", &v("0.99.0"), Some(&MATRIX), None).is_ok()); // same major
assert!(evaluate("tx3c", &v("1.0.0"), Some(&MATRIX), None).is_err()); // next major
}

#[test]
fn project_min_raises_floor_above_matrix() {
// The matrix allows >= 0.18, but the project demands >= 0.22.
let err = evaluate("tx3c", &v("0.20.0"), Some(&MATRIX), Some(&v("0.22.0"))).unwrap_err();
assert!(err.contains("this protocol requires"), "got: {err}");
assert!(evaluate("tx3c", &v("0.22.0"), Some(&MATRIX), Some(&v("0.22.0"))).is_ok());
}

#[test]
fn matrix_upper_bound_applies_even_when_project_min_satisfied() {
// Project min is met, but the tool is newer than this trix supports.
let err = evaluate("tx3c", &v("1.5.0"), Some(&MATRIX), Some(&v("0.22.0"))).unwrap_err();
assert!(err.contains("newer than this trix supports"), "got: {err}");
}

const BASE_TOML: &str = "\
[protocol]
name = \"x\"
version = \"0.1.0\"
main = \"main.tx3\"
[ledger]
family = \"cardano\"
";

fn config(toml_src: &str) -> RootConfig {
toml::from_str(toml_src).unwrap()
}

#[test]
fn collects_declared_tx3c_min() {
let cfg = config(&format!("{BASE_TOML}[toolchain]\ntx3c = \"0.22.0\"\n"));
let mins = collect_project_mins(&cfg).unwrap();
assert_eq!(mins.get("tx3c"), Some(&v("0.22.0")));
}

#[test]
fn absent_toolchain_table_yields_no_mins() {
let cfg = config(BASE_TOML);
assert!(collect_project_mins(&cfg).unwrap().is_empty());
}

#[test]
fn invalid_declared_version_is_rejected() {
let cfg = config(&format!("{BASE_TOML}[toolchain]\ntx3c = \"not-semver\"\n"));
assert!(collect_project_mins(&cfg).is_err());
}
}
Loading