use std::collections::BTreeMap;
use anyhow::{Context, Result};
use ark_bn254::Fr;
use clap::{Parser, Subcommand};
use tracing_subscriber::EnvFilter;
use xark_ir::primitive::{PrimitiveProgram, VarRole};
use xark_ir::profile::ProfileProgram;
use xark_ir::solver::Fp;
use xark_ir::{R1csProgram, VarId, Visibility};
#[inline]
pub(crate) fn dbg_flag(name: &str) -> bool {
#[cfg(feature = "debug")]
{
std::env::var(name).is_ok()
}
#[cfg(not(feature = "debug"))]
{
let _ = name;
false
}
}
pub mod ceremony;
pub mod check;
pub mod client;
pub mod completions;
pub mod doctor;
pub mod export;
pub mod inspect;
pub mod profile;
pub mod prove;
pub mod setup;
pub mod verify;
pub fn synth_err<E: std::fmt::Display>(e: E) -> anyhow::Error {
anyhow::anyhow!("R1CS synthesis error: {e}")
}
#[derive(Parser, Debug)]
#[command(
name = "xark",
version = concat!(env!("CARGO_PKG_VERSION"), " (", env!("XARK_GIT_HASH"), ")"),
styles = crate::style::clap_styles(),
about = "Write, compile, prove and verify zero-knowledge circuits in Rust",
long_about = "xark compiles a restricted Rust circuit (via rustc MIR) into \
xark-IR + R1CS, then proves and verifies it with an Arkworks Groth16 backend \
over BN254. `xark build` emits `circuit.json` + `r1cs.json` under \
`<crate>/target/xark/`; the backend commands read them from there \
automatically."
)]
pub struct Cli {
#[command(subcommand)]
pub command: Command,
}
#[derive(Subcommand, Debug)]
pub enum Command {
Doctor(doctor::DoctorArgs),
#[command(alias = "new")]
Init(InitArgs),
Build(BuildArgs),
Check(CheckArgs),
Test(TestArgs),
Clean(CleanArgs),
Setup(setup::SetupArgs),
Prove(prove::ProveArgs),
Verify(verify::VerifyArgs),
Export(export::ExportArgs),
Client(client::ClientArgs),
Ceremony(ceremony::CeremonyArgs),
Inspect(inspect::InspectArgs),
Profile(profile::ProfileArgs),
Completions(completions::CompletionsArgs),
}
pub fn main() {
let filter = EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new("info"));
tracing_subscriber::fmt()
.with_env_filter(filter)
.with_target(false)
.with_writer(std::io::stderr)
.init();
let cli = Cli::parse();
let code = match run(cli) {
Ok(()) => 0,
Err(e) => {
eprintln!("{} {e:#}", crate::style::err("error:"));
1
}
};
std::process::exit(code);
}
fn run(cli: Cli) -> Result<()> {
match cli.command {
Command::Doctor(a) => doctor::run(a),
Command::Init(a) => exit_code("init", crate::cli::cmd_init(&a.to_argv())),
Command::Build(a) => exit_code("build", crate::cli::cmd_build(&a.to_argv())),
Command::Check(a) if a.inputs.is_none() => {
exit_code("check", crate::cli::cmd_check(&a.to_argv()))
}
Command::Check(a) => check::run(a),
Command::Test(a) => exit_code("test", crate::cli::cmd_test(&a.to_argv())),
Command::Clean(a) => exit_code("clean", crate::cli::cmd_clean(&a.to_argv())),
Command::Setup(a) => setup::run(a),
Command::Prove(a) => prove::run(a),
Command::Verify(a) => verify::run(a),
Command::Export(a) => export::run(a),
Command::Client(a) => client::run(a),
Command::Ceremony(a) => ceremony::run(a),
Command::Inspect(a) => inspect::run(a),
Command::Profile(a) => profile::run(a),
Command::Completions(a) => completions::run(a),
}
}
fn exit_code(_name: &str, code: i32) -> Result<()> {
if code == 0 {
Ok(())
} else {
std::process::exit(code);
}
}
#[derive(clap::Args, Debug)]
pub struct InitArgs {
pub name: Option<String>,
}
impl InitArgs {
fn to_argv(&self) -> Vec<String> {
self.name.iter().cloned().collect()
}
}
#[derive(clap::Args, Debug)]
pub struct BuildArgs {
#[arg(default_value = ".", value_hint = clap::ValueHint::DirPath)]
pub crate_dir: String,
#[arg(long, value_hint = clap::ValueHint::DirPath)]
pub out: Option<String>,
#[arg(long, default_value = "bn254")]
pub field: String,
#[arg(long = "emit-json", default_value_t = false)]
pub emit_json: bool,
}
impl BuildArgs {
fn to_argv(&self) -> Vec<String> {
let mut v = vec![self.crate_dir.clone()];
if let Some(out) = &self.out {
v.push("--out".into());
v.push(out.clone());
}
v.push("--field".into());
v.push(self.field.clone());
if self.emit_json {
v.push("--emit-json".into());
}
v
}
}
#[derive(clap::Args, Debug)]
pub struct CleanArgs {}
impl CleanArgs {
fn to_argv(&self) -> Vec<String> {
Vec::new()
}
}
#[derive(clap::Args, Debug)]
pub struct CheckArgs {
#[arg(default_value = ".", value_hint = clap::ValueHint::DirPath)]
pub crate_dir: String,
#[arg(long = "message-format", value_parser = ["json", "human"])]
pub message_format: Option<String>,
#[arg(long = "inputs", value_name = "JSON|FILE")]
pub inputs: Option<String>,
#[arg(long, value_hint = clap::ValueHint::DirPath)]
pub out: Option<String>,
}
impl CheckArgs {
fn to_argv(&self) -> Vec<String> {
let mut v = vec![self.crate_dir.clone()];
if self.message_format.as_deref() == Some("json") {
v.push("--message-format=json".into());
}
v
}
}
#[derive(clap::Args, Debug)]
pub struct TestArgs {
#[arg(default_value = ".", value_hint = clap::ValueHint::DirPath)]
pub crate_dir: String,
#[arg(last = true)]
pub cargo_args: Vec<String>,
}
impl TestArgs {
fn to_argv(&self) -> Vec<String> {
let mut v = vec![self.crate_dir.clone()];
if !self.cargo_args.is_empty() {
v.push("--".into());
v.extend(self.cargo_args.iter().cloned());
}
v
}
}
pub fn path_arg(path: &Option<std::path::PathBuf>) -> String {
match path {
Some(p) => p.display().to_string(),
None => ".".to_string(),
}
}
pub fn parse_inputs_arg(arg: &str) -> Result<BTreeMap<String, String>> {
if arg.trim_start().starts_with('{') {
parse_input_text(arg, std::path::Path::new("<--inputs>"))
} else {
let text = std::fs::read_to_string(arg)
.with_context(|| format!("reading --inputs file `{arg}`"))?;
parse_input_text(&text, std::path::Path::new(arg))
}
}
pub fn parse_input_text(text: &str, source: &std::path::Path) -> Result<BTreeMap<String, String>> {
let mut out = BTreeMap::new();
if text.trim_start().starts_with('{') {
let obj: serde_json::Value = serde_json::from_str(text)
.with_context(|| format!("parsing {} as JSON", source.display()))?;
let map = obj.as_object().ok_or_else(|| {
anyhow::anyhow!("{}: expected a JSON object of name→value", source.display())
})?;
for (name, value) in map {
let v = match value {
serde_json::Value::String(s) => s.clone(),
serde_json::Value::Number(n) => n.to_string(),
other => anyhow::bail!(
"{}: input `{name}` must be a decimal string or number, got {other}",
source.display()
),
};
out.insert(name.clone(), v);
}
return Ok(out);
}
for (lineno, raw) in text.lines().enumerate() {
let line = raw.split('#').next().unwrap_or("").trim();
if line.is_empty() {
continue;
}
let (name, value) = line.split_once('=').with_context(|| {
format!(
"{}:{}: expected `name = value`",
source.display(),
lineno + 1
)
})?;
let value = value.trim().trim_matches('"').trim_matches('\'');
out.insert(name.trim().to_string(), value.to_string());
}
Ok(out)
}
pub fn inputs_hint(names: &[&str]) -> String {
let body = names
.iter()
.map(|n| format!("\"{n}\": <value>"))
.collect::<Vec<_>>()
.join(", ");
format!("--inputs '{{{body}}}'")
}
pub fn load_r1cs(path: &std::path::Path) -> Result<R1csProgram> {
let s = std::fs::read_to_string(path)
.with_context(|| format!("reading {} (run `xark build` first?)", path.display()))?;
xark_ir::json::from_json(&s).with_context(|| format!("parsing {}", path.display()))
}
pub fn load_circuit(path: &std::path::Path) -> Result<PrimitiveProgram> {
let s = std::fs::read_to_string(path)
.with_context(|| format!("reading {} (run `xark build` first?)", path.display()))?;
xark_ir::primitive::from_json(&s).with_context(|| format!("parsing {}", path.display()))
}
pub fn is_function_artifact(bytes: &[u8]) -> bool {
bytes.len() >= 6 && bytes[0..4] == *b"XBC\0" && u16::from_le_bytes([bytes[4], bytes[5]]) == 1
}
pub fn load_circuit_program(path: &std::path::Path) -> Result<xark_ir::CircuitProgram> {
let bytes = std::fs::read(path)
.with_context(|| format!("reading {} (run `xark build` first?)", path.display()))?;
if !is_function_artifact(&bytes) {
anyhow::bail!(
"{} is not a current circuit artifact — rebuild with `xark build`",
path.display()
);
}
xark_ir::function_decode::expand_function_blob(&bytes).map_err(|e| anyhow::anyhow!(e))
}
pub fn load_backend_r1cs(
xbc_path: &std::path::Path,
r1cs_override: Option<&std::path::Path>,
r1cs_default: &std::path::Path,
) -> Result<(R1csProgram, String)> {
if let Some(p) = r1cs_override {
let s = std::fs::read_to_string(p)
.with_context(|| format!("reading {} (run `xark build` first?)", p.display()))?;
let prog =
xark_ir::json::from_json(&s).with_context(|| format!("parsing {}", p.display()))?;
return Ok((prog, sha256_hex(s.as_bytes())));
}
if xbc_path.exists() {
let bytes =
std::fs::read(xbc_path).with_context(|| format!("reading {}", xbc_path.display()))?;
let fingerprint = sha256_hex(&bytes);
if !is_function_artifact(&bytes) {
anyhow::bail!(
"{} is not a current circuit artifact — rebuild with `xark build`",
xbc_path.display()
);
}
let use_reduced = !dbg_flag("XARK_FLAT_MINIMIZE") && !dbg_flag("XARK_NO_MINIMIZE");
let cp = if use_reduced {
xark_ir::function_decode::expand_function_blob_reduced(&bytes)
} else {
xark_ir::function_decode::expand_function_blob(&bytes)
}
.map_err(|e| anyhow::anyhow!(e))?;
return Ok((cp.into_r1cs(), fingerprint));
}
let s = std::fs::read_to_string(r1cs_default).with_context(|| {
format!(
"reading {} (run `xark build` first?)",
r1cs_default.display()
)
})?;
let prog = xark_ir::json::from_json(&s)
.with_context(|| format!("parsing {}", r1cs_default.display()))?;
Ok((prog, sha256_hex(s.as_bytes())))
}
pub fn load_circuit_xbc_parallel(path: &std::path::Path) -> Result<PrimitiveProgram> {
load_circuit_program(path).map(|cp| cp.to_primitive())
}
pub fn load_circuit_auto(path: &std::path::Path) -> Result<PrimitiveProgram> {
if path.extension().is_some_and(|e| e == "xbc") {
load_circuit_xbc_parallel(path)
} else {
load_circuit(path)
}
}
pub fn load_profile(dir: &std::path::Path) -> Option<ProfileProgram> {
std::fs::read_to_string(dir.join("profile.json"))
.ok()
.and_then(|s| xark_ir::profile::from_json(&s).ok())
}
fn parse_hex_bytes(s: &str) -> Result<Vec<u8>> {
let h = s
.strip_prefix("0x")
.or_else(|| s.strip_prefix("0X"))
.ok_or_else(|| anyhow::anyhow!("expected a 0x-prefixed hex string, got `{s}`"))?;
hex::decode(h).map_err(|e| anyhow::anyhow!("invalid hex `{s}`: {e}"))
}
fn value_to_be_bytes(v: &str) -> Result<Vec<u8>> {
if v.starts_with("0x") || v.starts_with("0X") {
parse_hex_bytes(v)
} else {
num_bigint::BigUint::parse_bytes(v.trim().as_bytes(), 10)
.map(|n| n.to_bytes_be())
.ok_or_else(|| anyhow::anyhow!("expected a decimal or `0x`-hex value, got `{v}`"))
}
}
fn limb_bits(n_limbs: usize) -> Result<u32> {
match n_limbs {
2 => Ok(128),
3 => Ok(86),
n => anyhow::bail!("unsupported {n}-limb layout (expected 2×128 or 3×86)"),
}
}
fn count_limbs(vars: &[xark_ir::primitive::Var], prefix: &str) -> usize {
let key = format!("{prefix}.limbs");
vars.iter()
.filter(|v| array_index(&v.name, &key).is_some())
.count()
}
fn sub_fields(vars: &[xark_ir::primitive::Var], prefix: &str) -> Vec<String> {
let pre = format!("{prefix}.");
let mut fs: Vec<String> = vars
.iter()
.filter_map(|v| {
let (f, tail) = v.name.strip_prefix(&pre)?.split_once('.')?;
tail.starts_with("limbs[").then(|| f.to_string())
})
.collect();
fs.sort_unstable();
fs.dedup();
fs
}
fn array_index(leaf: &str, name: &str) -> Option<usize> {
let inner = leaf
.strip_prefix(name)?
.strip_prefix('[')?
.strip_suffix(']')?;
if inner.contains('[') {
return None;
}
inner.parse().ok()
}
fn digest_index(leaf: &str, name: &str) -> Option<(usize, usize)> {
let rest = leaf.strip_prefix(name)?.strip_prefix(".bits[")?;
let (w, rest) = rest.split_once("][")?;
let j = rest.strip_suffix(']')?;
Some((w.parse().ok()?, j.parse().ok()?))
}
fn logical_root(leaf: &str) -> &str {
let cut = leaf.find(['[', '.']).unwrap_or(leaf.len());
&leaf[..cut]
}
pub fn resolve_input_ids(
vars: &[xark_ir::primitive::Var],
inputs: &BTreeMap<String, String>,
) -> Result<BTreeMap<VarId, String>> {
let by_name: BTreeMap<&str, VarId> = vars.iter().map(|v| (v.name.as_str(), v.id)).collect();
let is_hex = |s: &str| s.starts_with("0x") || s.starts_with("0X");
let mut id_inputs: BTreeMap<VarId, String> = BTreeMap::new();
for (k, v) in inputs {
if let Some(&id) = by_name.get(k.as_str()) {
let dec = if is_hex(v) {
xark_prover::hex_to_field_decimal(v)
.map_err(|e| anyhow::anyhow!("input `{k}`: {e}"))?
} else {
xark_prover::try_fr_from_decimal(v)
.map_err(|e| anyhow::anyhow!("invalid value for input `{k}`: {e}"))?;
v.clone()
};
id_inputs.insert(id, dec);
continue;
}
let mut arr: Vec<(usize, VarId)> = vars
.iter()
.filter_map(|var| array_index(&var.name, k).map(|i| (i, var.id)))
.collect();
if !arr.is_empty() {
arr.sort_unstable_by_key(|(i, _)| *i);
let bytes = parse_hex_bytes(v).map_err(|e| anyhow::anyhow!("input `{k}`: {e}"))?;
if bytes.len() != arr.len() {
anyhow::bail!(
"input `{k}` is a {}-byte array, but the hex value has {} bytes",
arr.len(),
bytes.len()
);
}
for ((_, id), byte) in arr.iter().zip(bytes) {
id_inputs.insert(*id, byte.to_string());
}
continue;
}
if let (Some(&hi_id), Some(&lo_id)) = (
by_name.get(format!("{k}.hi").as_str()),
by_name.get(format!("{k}.lo").as_str()),
) {
let bytes = parse_hex_bytes(v).map_err(|e| anyhow::anyhow!("input `{k}`: {e}"))?;
if bytes.len() != 32 {
anyhow::bail!(
"hash input `{k}` needs a 32-byte hex value, but got {} bytes",
bytes.len()
);
}
let pack = |chunk: &[u8]| chunk.iter().fold(0u128, |acc, &b| (acc << 8) | b as u128);
id_inputs.insert(hi_id, pack(&bytes[..16]).to_string());
id_inputs.insert(lo_id, pack(&bytes[16..]).to_string());
continue;
}
let dig: Vec<((usize, usize), VarId)> = vars
.iter()
.filter_map(|var| digest_index(&var.name, k).map(|wj| (wj, var.id)))
.collect();
if !dig.is_empty() {
let bytes = parse_hex_bytes(v).map_err(|e| anyhow::anyhow!("input `{k}`: {e}"))?;
if bytes.len() != 32 {
anyhow::bail!(
"digest input `{k}` needs a 32-byte hex value, but got {} bytes",
bytes.len()
);
}
for ((w, j), id) in dig {
let byte = bytes[4 * w + (3 - j / 8)];
id_inputs.insert(id, ((byte >> (j % 8)) & 1).to_string());
}
continue;
}
let n_scalar = count_limbs(vars, k);
if n_scalar > 0 {
let be = value_to_be_bytes(v).map_err(|e| anyhow::anyhow!("input `{k}`: {e}"))?;
let bits = limb_bits(n_scalar)?;
for (name, dec) in xark_prover::limb_leaves(&be, k, n_scalar, bits) {
if let Some(&id) = by_name.get(name.as_str()) {
id_inputs.insert(id, dec);
}
}
continue;
}
let fields = sub_fields(vars, k);
if !fields.is_empty() {
let mut bytes = parse_hex_bytes(v).map_err(|e| anyhow::anyhow!("input `{k}`: {e}"))?;
let want = fields.len() * 32;
if bytes.len() == want + 1 && bytes[0] == 0x04 {
bytes.remove(0); }
if bytes.len() != want {
anyhow::bail!(
"compound input `{k}` ({} fields: {fields:?}) needs a {want}-byte \
big-endian value{}, but got {} bytes",
fields.len(),
if fields.len() == 2 {
" (or 65 with the 0x04 SEC1 tag)"
} else {
""
},
bytes.len()
);
}
for (i, f) in fields.iter().enumerate() {
let chunk = &bytes[i * 32..(i + 1) * 32];
let n = count_limbs(vars, &format!("{k}.{f}"));
for (name, dec) in
xark_prover::limb_leaves(chunk, &format!("{k}.{f}"), n, limb_bits(n)?)
{
if let Some(&id) = by_name.get(name.as_str()) {
id_inputs.insert(id, dec);
}
}
}
continue;
}
let mut roots: Vec<&str> = vars
.iter()
.filter(|v| !matches!(v.role, VarRole::Derived))
.map(|v| logical_root(&v.name))
.collect();
roots.sort_unstable();
roots.dedup();
anyhow::bail!("unknown input `{k}` (circuit inputs: {roots:?})");
}
Ok(id_inputs)
}
pub fn soundness_check(
cp: &xark_ir::CircuitProgram,
profile: Option<&ProfileProgram>,
id_inputs: &BTreeMap<VarId, String>,
) -> Result<BTreeMap<VarId, Fp>> {
let describe = |e| {
let r1cs = cp.to_r1cs();
anyhow::anyhow!(
"{}",
xark_ir::diagnose::describe_unsatisfied(&e, &r1cs, profile)
)
};
let timing = dbg_flag("PROVE_TIME");
let t = std::time::Instant::now();
let assign_fp = xark_ir::solver::solve_cp(cp, id_inputs).map_err(describe)?;
let t_solve = t.elapsed();
let t = std::time::Instant::now();
xark_ir::solver::check_cp(cp, &assign_fp).map_err(describe)?;
let t_check = t.elapsed();
let t = std::time::Instant::now();
let holes = xark_ir::solver::analyze_underconstrained_cp(cp, &assign_fp);
if timing {
eprintln!(
"PROVE_TIME: solve={:?} check={:?} analyze={:?}",
t_solve,
t_check,
t.elapsed()
);
}
report_underconstrained(holes)?;
Ok(assign_fp)
}
pub fn soundness_check_r1cs(
prim: &PrimitiveProgram,
r1cs: &R1csProgram,
profile: Option<&ProfileProgram>,
id_inputs: &BTreeMap<VarId, String>,
) -> Result<BTreeMap<VarId, Fp>> {
let assign_fp = xark_ir::solver::solve_and_check(prim, id_inputs).map_err(|e| {
if let xark_ir::solver::SolveError::MissingInput(id) = e
&& let Some(v) = prim.vars.iter().find(|v| v.id == id)
{
return anyhow::anyhow!(
"missing input `{0}` (add it to --inputs, e.g. --inputs '{{\"{0}\": <value>}}')",
v.name
);
}
anyhow::anyhow!(
"{}",
xark_ir::diagnose::describe_unsatisfied(&e, r1cs, profile)
)
})?;
report_underconstrained(xark_ir::solver::analyze_underconstrained(prim, &assign_fp))?;
Ok(assign_fp)
}
fn report_underconstrained(holes: Vec<xark_ir::solver::UnderConstrained>) -> Result<()> {
if !holes.is_empty() {
let mut msg = crate::style::err(
"circuit is under-constrained: a malicious prover could forge the \
following derived variable(s) without violating any constraint, so \
any proof of this circuit is unsound:",
);
for h in &holes {
msg.push_str(&format!("\n - `{}` (var {}): {}", h.name, h.var, h.reason));
}
msg.push_str(
"\nevery hint/advice value (`hint_inverse`, `hint_bit`, `advice`, the \
bignum hints) must be pinned by a constraint (e.g. `require_eq`).",
);
anyhow::bail!(msg);
}
Ok(())
}
pub fn num_public_inputs(prog: &R1csProgram) -> usize {
prog.variables
.iter()
.filter(|v| v.visibility == Visibility::Public)
.count()
}
pub fn sha256_hex(bytes: &[u8]) -> String {
use sha2::{Digest, Sha256};
hex::encode(Sha256::digest(bytes))
}
pub fn circuit_hash(r1cs_json: &str) -> String {
sha256_hex(r1cs_json.as_bytes())
}
pub fn public_inputs_from_inputs(
prog: &R1csProgram,
inputs: &BTreeMap<String, String>,
) -> Result<Vec<Fr>> {
let mut vars: Vec<_> = prog
.variables
.iter()
.filter(|v| v.visibility == Visibility::Public)
.collect();
vars.sort_by_key(|v| v.id);
let mut out = Vec::with_capacity(vars.len());
for v in vars {
let value = inputs.get(&v.name).with_context(|| {
format!(
"missing public input `{0}` (add it to --inputs, e.g. --inputs '{{\"{0}\": <value>}}')",
v.name
)
})?;
let fr = xark_prover::try_fr_from_decimal(value)
.map_err(|e| anyhow::anyhow!("public input `{}`: {e}", v.name))?;
out.push(fr);
}
Ok(out)
}
#[cfg(test)]
mod tests {
use super::{inputs_hint, parse_input_text, parse_inputs_arg};
use std::path::Path;
#[test]
fn parses_line_input_file() {
let inputs = parse_input_text(
"# witness\npath[0] = 12\namount = '34' # inline comment\n",
Path::new("witness.inputs"),
)
.unwrap();
assert_eq!(inputs.get("path[0]").map(String::as_str), Some("12"));
assert_eq!(inputs.get("amount").map(String::as_str), Some("34"));
}
#[test]
fn parses_json_without_losing_large_values() {
let inputs = parse_input_text(
r#"{"amount": 34, "field": "21888242871839275222246405745257275088548364400416034343698204186575808495616"}"#,
Path::new("witness.json"),
)
.unwrap();
assert_eq!(inputs.get("amount").map(String::as_str), Some("34"));
assert_eq!(
inputs.get("field").map(String::as_str),
Some("21888242871839275222246405745257275088548364400416034343698204186575808495616")
);
}
#[test]
fn rejects_non_scalar_json_values() {
let err = parse_input_text(r#"{"path": [1, 2]}"#, Path::new("witness.json")).unwrap_err();
assert!(
err.to_string()
.contains("input `path` must be a decimal string or number")
);
}
#[test]
fn inputs_arg_accepts_inline_json() {
let inputs = parse_inputs_arg(r#"{"secret": 3, "result": 27}"#).unwrap();
assert_eq!(inputs.get("secret").map(String::as_str), Some("3"));
assert_eq!(inputs.get("result").map(String::as_str), Some("27"));
}
#[test]
fn inputs_arg_reads_a_file_path() {
let dir = std::env::temp_dir().join("xark-inputs-arg-test");
std::fs::create_dir_all(&dir).unwrap();
let path = dir.join("w.inputs");
std::fs::write(&path, "secret = 3\nresult = 27\n").unwrap();
let inputs = parse_inputs_arg(path.to_str().unwrap()).unwrap();
assert_eq!(inputs.get("secret").map(String::as_str), Some("3"));
assert_eq!(inputs.get("result").map(String::as_str), Some("27"));
}
#[test]
fn hint_renders_a_json_template() {
assert_eq!(
inputs_hint(&["secret", "result"]),
r#"--inputs '{"secret": <value>, "result": <value>}'"#
);
}
}