on master help sign in
Extend CLI-overridable types for #name args

Support refined scalars (with startup predicate checks), enums (all-nullary ADTs), Bytes (URL-safe base64), Uuid, and relations of scalars (repeatable/comma-separated flags), plus Maybe variants of each. - scalar_override_type classifies Bytes/Uuid/Rel<scalar>/refined:<n>/ enum:<n> in addition to the bare scalars - new runtime entry knot_override_lookup_ex parses the extended descriptor forms; tags 0-7 keep the legacy scalar path - refined overrides run the refinement chain at startup via knot_override_check_refined; a violation prints the flag, value, and type and exits 1 - enum values are validated against the ADT's constructor list - Bytes accepts URL-safe base64 only (rejects the standard alphabet) - --help renders refined:/enum: descriptors as the user-facing type name Co-authored-by: vix <290354907+vix-agent@users.noreply.github.com>

commita229ffef0f93a24ce7c3070f0a6f21165a66ec68 authoriko <ilyakooo0@gmail.com> dateThu 3 Sep 2026, 15:32 (1 month ago) parentc67a2b4
3 files changed+1362−157
crates/knot-compiler/src/codegen.rs+598−99 crates/knot-jit/tests/cli_overrides.rs+416 crates/knot-runtime/src/lib.rs+348−58
crates/knot-compiler/src/codegen.rs+598−99
@@ -310,7 +310,9 @@ pub struct Codegen<M: cranelift_module::Module = ObjectModule> {
310310 /// the value as a one-row relation; reads unwrap it to the bare value.
311311 single_value_sources: HashSet<String>,
312312
313− // Overridable constants: name -> type string ("Int", "Float", "Text", "Bool")
313+ // Overridable constants: name -> type descriptor string ("Int", "Float",
314+ // "Text", "Bool", "Maybe <scalar>", "Bytes", "Uuid", "Rel <scalar>",
315+ // "refined:<type-name>", "enum:<type-name>")
314316 overridable_constants: HashMap<String, String>,
315317
316318 // Default-value display strings for overridable constants whose body is a
@@ -323,6 +325,12 @@ pub struct Codegen<M: cranelift_module::Module = ObjectModule> {
323325 // Compile-time constant overrides: name -> raw string value (parsed during codegen)
324326 compile_time_overrides: HashMap<String, String>,
325327
328+ /// All-nullary ADTs eligible for CLI override: type name -> constructor
329+ /// names. Populated from decl views before `overridable_constants` is
330+ /// computed; codegen uses it to emit the valid-constructor descriptor for
331+ /// `enum:` overrides.
332+ override_adt_ctors: HashMap<String, Vec<String>>,
333+
326334 // Whether we are inside compile_io_do_eager — when true, Yield compiles to
327335 // the raw inner value rather than wrapping in knot_relation_singleton.
328336 in_io_eager: bool,
@@ -590,40 +598,108 @@ struct LoopInfo {
590598 arena_mark: Value,
591599}
592600
593−/// Resolve a constant's inferred type, as rendered by inference, to the scalar
594−/// type the runtime knows how to parse from argv. Returns one of the eight
595−/// strings `define_user_function` maps to a type tag, or `None` when the
596−/// constant is not CLI-overridable.
601+/// Decode URL-safe base64 (RFC 4648 §5) at compile time — mirrors the
602+/// runtime's `decode_base64url`. Padding optional; standard-alphabet
603+/// characters rejected.
604+fn decode_base64url_compile(s: &str) -> Option<Vec<u8>> {
605+ if s.is_empty() {
606+ return Some(Vec::new());
607+ }
608+ let mut out = Vec::with_capacity(s.len() * 3 / 4);
609+ let mut buf: u32 = 0;
610+ let mut nbits: u32 = 0;
611+ for &b in s.as_bytes() {
612+ let v: u32 = match b {
613+ b'A'..=b'Z' => (b - b'A') as u32,
614+ b'a'..=b'z' => (b - b'a' + 26) as u32,
615+ b'0'..=b'9' => (b - b'0' + 52) as u32,
616+ b'-' => 62,
617+ b'_' => 63,
618+ b'=' => continue,
619+ _ => return None,
620+ };
621+ buf = (buf << 6) | v;
622+ nbits += 6;
623+ if nbits >= 8 {
624+ nbits -= 8;
625+ out.push((buf >> nbits) as u8);
626+ }
627+ }
628+ if nbits >= 6 || (nbits > 0 && (buf & ((1 << nbits) - 1)) != 0) {
629+ return None;
630+ }
631+ Some(out)
632+}
633+
634+/// Resolve a constant's inferred type, as rendered by inference, to the type
635+/// descriptor the runtime knows how to parse from argv. Returns one of the
636+/// strings `define_user_function` maps to a type tag (`Int`, `Float`, `Text`,
637+/// `Bool`, and `Maybe ` variants), one of the extended descriptors (`Bytes`,
638+/// `Uuid`, `Rel <scalar>`, `refined:<name>`, `enum:<name>`), or `None` when
639+/// the constant is not CLI-overridable.
597640///
598−/// A constant annotated with an alias of a scalar (`type Host = Text`) renders
599−/// as the alias name, so matching the rendered string alone would drop it and
600−/// leave the constant with no `knot_override_lookup` call — the flag would be
601−/// accepted and silently ignored. Resolving through `type_aliases` keeps this
602−/// correct for aliased scalars.
641+/// A constant annotated with an alias of a supported type (`type Host = Text`)
642+/// renders as the alias name, so matching the rendered string alone would
643+/// drop it and leave the constant with no `knot_override_lookup` call — the
644+/// flag would be accepted and silently ignored. Resolving through
645+/// `type_aliases` keeps this correct for aliased scalars.
603646///
604−/// Refined aliases are deliberately excluded: nothing here runs their
605−/// predicate, so honouring the flag would let a CLI value in through a check
606−/// the type promises.
647+/// Refined aliases are deliberately NOT resolved to their base type: the
648+/// predicate must run on the CLI value, so they classify as `refined:<name>`
649+/// and codegen emits the startup predicate check. Non-scalar aliases
650+/// (records, functions, relations of compound rows) are not overridable.
607651fn scalar_override_type(
608652 ty_str: &str,
609653 type_aliases: &HashMap<String, ResolvedType>,
610654 refined_types: &HashMap<String, ast::Expr>,
655+ adt_ctors: &HashMap<String, Vec<String>>,
611656) -> Option<String> {
612657 let (prefix, base) = match ty_str.strip_prefix("Maybe ") {
613658 Some(rest) => ("Maybe ", rest),
614659 None => ("", ty_str),
615660 };
661+ // Strip one layer of parens: `Maybe (Int Ms)` renders the arg
662+ // parenthesized.
663+ let base = base
664+ .strip_prefix('(')
665+ .and_then(|r| r.strip_suffix(')'))
666+ .unwrap_or(base);
616667 let resolved = match base {
617− "Int" | "Float" | "Text" | "Bool" => base,
668+ "Int" | "Float" | "Text" | "Bool" => base.to_string(),
669+ "Bytes" => "Bytes".to_string(),
670+ "Uuid" => "Uuid".to_string(),
671+ name if refined_types.contains_key(name) => format!("refined:{name}"),
672+ name if adt_ctors.contains_key(name) => format!("enum:{name}"),
673+ // Unit-bearing scalars (`Int Ms`, `Float 1`): CLI parsing is
674+ // unit-agnostic, classify by the base scalar.
675+ name if name.starts_with("Int ") => "Int".to_string(),
676+ name if name.starts_with("Float ") => "Float".to_string(),
677+ name if name.starts_with("Rel ") => {
678+ let elem = name[4..].trim();
679+ let elem = elem
680+ .strip_prefix('(')
681+ .and_then(|r| r.strip_suffix(')'))
682+ .unwrap_or(elem);
683+ let elem_base = match elem {
684+ "Int" | "Float" | "Text" | "Bool" => elem,
685+ e if e.starts_with("Int ") => "Int",
686+ e if e.starts_with("Float ") => "Float",
687+ _ => return None,
688+ };
689+ format!("Rel {elem_base}")
690+ }
618691 name => {
619− if refined_types.contains_key(name) {
620− return None;
621− }
622692 match type_aliases.get(name)? {
623− ResolvedType::Int => "Int",
624− ResolvedType::Float => "Float",
625− ResolvedType::Text => "Text",
626− ResolvedType::Bool => "Bool",
693+ ResolvedType::Int => "Int".to_string(),
694+ ResolvedType::Float => "Float".to_string(),
695+ ResolvedType::Text => "Text".to_string(),
696+ ResolvedType::Bool => "Bool".to_string(),
697+ ResolvedType::Bytes => "Bytes".to_string(),
698+ ResolvedType::Uuid => "Uuid".to_string(),
699+ // An alias of an all-nullary ADT resolves to Adt.
700+ ResolvedType::Adt(_) if adt_ctors.contains_key(name) => {
701+ format!("enum:{name}")
702+ }
627703 _ => return None,
628704 }
629705 }
@@ -998,6 +1074,24 @@ fn compile_inner<M: cranelift_module::Module>(
9981074 }
9991075 cg.declare_runtime_fns();
10001076 cg.collect_declarations(program);
1077+ // All-nullary ADTs ("enums") are CLI-overridable by constructor name.
1078+ // Collect them up front from the decl views: a `data` decl (or variant
1079+ // type alias) whose every constructor has no fields. Field-less
1080+ // constructor defs parse with an empty field list; any field disqualifies
1081+ // the whole type (payload constructors can't be spelled as a flag value).
1082+ let mut adt_ctors: HashMap<String, Vec<String>> = HashMap::new();
1083+ for decl in decl_views(program) {
1084+ if let DeclViewKind::Data { params, ctors } = decl.kind
1085+ && params.is_empty()
1086+ && !ctors.is_empty()
1087+ && ctors.iter().all(|c| c.fields.is_empty())
1088+ {
1089+ adt_ctors.insert(
1090+ decl.name.to_string(),
1091+ ctors.iter().map(|c| c.name.clone()).collect(),
1092+ );
1093+ }
1094+ }
10011095 // Compute overridable constants: 0-param Fun declarations with scalar types
10021096 for decl in decl_views(program) {
10031097 match decl.kind {
@@ -1010,8 +1104,12 @@ fn compile_inner<M: cranelift_module::Module>(
10101104 }
10111105 if let Some((_, 0)) = cg.global_fns.get(name)
10121106 && let Some(ty_str) = type_info.get(name)
1013− && let Some(base_type) =
1014− scalar_override_type(ty_str, &cg.type_aliases, &cg.refined_types)
1107+ && let Some(base_type) = scalar_override_type(
1108+ ty_str,
1109+ &cg.type_aliases,
1110+ &cg.refined_types,
1111+ &adt_ctors,
1112+ )
10151113 {
10161114 cg.overridable_constants.insert(name.to_string(), base_type);
10171115 if let Some(disp) = format_default_value_display(body) {
@@ -1025,8 +1123,12 @@ fn compile_inner<M: cranelift_module::Module>(
10251123 // default) are handled by the ArgDecl codegen arm.
10261124 let name = decl.name;
10271125 if let Some(ty_str) = type_info.get(name)
1028− && let Some(base_type) =
1029− scalar_override_type(ty_str, &cg.type_aliases, &cg.refined_types)
1126+ && let Some(base_type) = scalar_override_type(
1127+ ty_str,
1128+ &cg.type_aliases,
1129+ &cg.refined_types,
1130+ &adt_ctors,
1131+ )
10301132 {
10311133 cg.overridable_constants.insert(name.to_string(), base_type);
10321134 if let Some(d) = default
@@ -1039,6 +1141,9 @@ fn compile_inner<M: cranelift_module::Module>(
10391141 _ => {}
10401142 }
10411143 }
1144+ // Refined-type CLI overrides need their constructor-adjacent metadata:
1145+ // remember the descriptor so codegen can emit the predicate check.
1146+ cg.override_adt_ctors = adt_ctors;
10421147 // Validate and store compile-time overrides
10431148 for (name, val) in compile_time_overrides {
10441149 if let Some(ty_str) = cg.overridable_constants.get(name) {
@@ -1052,6 +1157,19 @@ fn compile_inner<M: cranelift_module::Module>(
10521157 "Float" | "Maybe Float" => "Float",
10531158 "Text" | "Maybe Text" => "Text",
10541159 "Bool" | "Maybe Bool" => "Bool",
1160+ "Bytes" | "Maybe Bytes" => "Bytes",
1161+ "Uuid" | "Maybe Uuid" => "Uuid",
1162+ s if s.starts_with("Rel ") || s.starts_with("Maybe Rel ") => "Rel",
1163+ s if s.strip_prefix("refined:").is_some()
1164+ || s.strip_prefix("Maybe refined:").is_some() =>
1165+ {
1166+ "Refined"
1167+ }
1168+ s if s.strip_prefix("enum:").is_some()
1169+ || s.strip_prefix("Maybe enum:").is_some() =>
1170+ {
1171+ "Enum"
1172+ }
10551173 _ => continue,
10561174 };
10571175 match base_type {
@@ -1082,6 +1200,64 @@ fn compile_inner<M: cranelift_module::Module>(
10821200 ));
10831201 continue;
10841202 }
1203+ "Bytes" => {
1204+ // base64url only (URL-safe alphabet, padding optional).
1205+ let valid = !val.is_empty()
1206+ && val.bytes().all(|b| {
1207+ b.is_ascii_alphanumeric() || b == b'-' || b == b'_' || b == b'='
1208+ });
1209+ if !valid {
1210+ cg.diagnostics.push(knot::diagnostic::Diagnostic::error(
1211+ format!("invalid compile-time override '{}' for --{} (expected base64url-encoded Bytes)", val, name),
1212+ ));
1213+ continue;
1214+ }
1215+ }
1216+ "Uuid" => {
1217+ let valid = val.len() == 36
1218+ && val.bytes().enumerate().all(|(i, b)| {
1219+ if i == 8 || i == 13 || i == 18 || i == 23 {
1220+ b == b'-'
1221+ } else {
1222+ b.is_ascii_hexdigit()
1223+ }
1224+ });
1225+ if !valid {
1226+ cg.diagnostics.push(knot::diagnostic::Diagnostic::error(
1227+ format!("invalid compile-time override '{}' for --{} (expected a Uuid xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx)", val, name),
1228+ ));
1229+ continue;
1230+ }
1231+ }
1232+ "Enum" => {
1233+ // The value must be one of the ADT's constructors.
1234+ let ty_name = ty_str
1235+ .strip_prefix("Maybe ")
1236+ .unwrap_or(ty_str)
1237+ .trim_start_matches("enum:");
1238+ let valid = cg
1239+ .override_adt_ctors
1240+ .get(ty_name)
1241+ .map(|ctors| ctors.iter().any(|c| c == val))
1242+ .unwrap_or(false);
1243+ if !valid {
1244+ let ctors = cg
1245+ .override_adt_ctors
1246+ .get(ty_name)
1247+ .map(|c| c.join(", "))
1248+ .unwrap_or_default();
1249+ cg.diagnostics.push(knot::diagnostic::Diagnostic::error(
1250+ format!("invalid compile-time override '{}' for --{} (expected one of: {})", val, name, ctors),
1251+ ));
1252+ continue;
1253+ }
1254+ }
1255+ // Refined: parsed as the base scalar at runtime; the
1256+ // predicate check happens on the CLI path. Compile-time
1257+ // overrides of refined constants are validated the same
1258+ // way as their base scalar (best effort: no parse check
1259+ // here beyond accepting the string).
1260+ "Refined" | "Rel" => {}
10851261 _ => {} // Text always valid
10861262 }
10871263 }
@@ -1514,6 +1690,7 @@ impl<M: cranelift_module::Module> Codegen<M> {
15141690 overridable_constants: HashMap::new(),
15151691 overridable_defaults: HashMap::new(),
15161692 compile_time_overrides: HashMap::new(),
1693+ override_adt_ctors: HashMap::new(),
15171694 in_io_eager: false,
15181695 atomic_retry_block: None,
15191696 io_loop_skip_block: None,
@@ -1711,6 +1888,9 @@ impl<M: cranelift_module::Module> Codegen<M> {
17111888
17121889 // CLI constant overrides
17131890 self.declare_rt("knot_override_lookup", &[p, p, types::I32], &[p]);
1891+ self.declare_rt("knot_override_lookup_ex", &[p, p, p, p], &[p]);
1892+ self.declare_rt("knot_override_unwrap_just", &[p], &[p]);
1893+ self.declare_rt("knot_override_check_refined", &[p, p, p, p, p, p], &[]);
17141894 self.declare_rt("knot_override_check_help", &[p, p], &[]);
17151895
17161896 // STM tracking
@@ -3795,48 +3975,44 @@ impl<M: cranelift_module::Module> Codegen<M> {
37953975
37963976 // For overridable constants, check CLI override before evaluating body
37973977 if let Some(type_str) = &override_type {
3798− let type_tag: Option<i64> = match type_str.as_str() {
3799− "Int" => Some(0),
3800− "Float" => Some(1),
3801− "Text" => Some(2),
3802− "Bool" => Some(3),
3803− "Maybe Int" => Some(4),
3804− "Maybe Float" => Some(5),
3805− "Maybe Text" => Some(6),
3806− "Maybe Bool" => Some(7),
3807− // Bytes/Uuid and other types have no CLI-override parser in
3808− // the runtime (knot_override_lookup only handles the scalar
3809− // types above). Skip the override for this constant rather
3810− // than crashing codegen.
3811− _ => {
3812− eprintln!(
3813− "knot: warning: constant '{}' of type '{}' does not support \
3814− command-line overrides; ignoring override for this constant",
3815− name_owned, type_str
3816− );
3817− None
3818− }
3819− };
3820− if let Some(type_tag) = type_tag {
3821− let (name_ptr, name_len) = cg.string_ptr(builder, &name_owned);
3822− let tag = builder.ins().iconst(types::I32, type_tag);
3823− let override_val =
3824− cg.call_rt(builder, "knot_override_lookup", &[name_ptr, name_len, tag]);
3825−
3826− let default_block = builder.create_block();
3827− let override_block = builder.create_block();
3828− let is_null = builder.ins().icmp_imm(IntCC::Equal, override_val, 0);
3829− builder
3830− .ins()
3831− .brif(is_null, default_block, &[], override_block, &[]);
3978+ let type_str = type_str.clone();
3979+ let (override_val, _) =
3980+ cg.emit_override_lookup(builder, &name_owned, &type_str);
3981+
3982+ // Refined-type override: the CLI value was parsed as the base
3983+ // scalar; run the refinement predicate(s) before it can flow
3984+ // into the program. Checked here (on the constant's own path)
3985+ // so every read of the constant sees a validated value.
3986+ if let Some(refined_name) = type_str
3987+ .strip_prefix("refined:")
3988+ .or_else(|| type_str.strip_prefix("Maybe refined:"))
3989+ {
3990+ let refined_name = refined_name.to_string();
3991+ let is_maybe = type_str.starts_with("Maybe ");
3992+ cg.emit_refined_override_check(
3993+ builder,
3994+ &name_owned,
3995+ &refined_name,
3996+ override_val,
3997+ is_maybe,
3998+ &mut env,
3999+ db,
4000+ );
4001+ }
38324002
3833− builder.switch_to_block(override_block);
3834− builder.seal_block(override_block);
3835− builder.ins().return_(&[override_val]);
4003+ let default_block = builder.create_block();
4004+ let override_block = builder.create_block();
4005+ let is_null = builder.ins().icmp_imm(IntCC::Equal, override_val, 0);
4006+ builder
4007+ .ins()
4008+ .brif(is_null, default_block, &[], override_block, &[]);
38364009
3837− builder.switch_to_block(default_block);
3838− builder.seal_block(default_block);
3839− }
4010+ builder.switch_to_block(override_block);
4011+ builder.seal_block(override_block);
4012+ builder.ins().return_(&[override_val]);
4013+
4014+ builder.switch_to_block(default_block);
4015+ builder.seal_block(default_block);
38404016 }
38414017
38424018 let result = cg.compile_expr(builder, &body_owned, &mut env, db);
@@ -4574,6 +4750,20 @@ impl<M: cranelift_module::Module> Codegen<M> {
45744750 // preserved by `splitn(3, ':')` at runtime)
45754751 // `name:type` — has default but not displayable
45764752 let descriptor = {
4753+ // Render the internal descriptor as the user-facing type:
4754+ // `refined:Port` → `Port`, `enum:Mode` → `Mode`, others
4755+ // pass through (already surface syntax).
4756+ let display_ty = |ty: &str| -> String {
4757+ let (prefix, rest) = match ty.strip_prefix("Maybe ") {
4758+ Some(r) => ("Maybe ", r),
4759+ None => ("", ty),
4760+ };
4761+ let base = rest
4762+ .strip_prefix("refined:")
4763+ .or_else(|| rest.strip_prefix("enum:"))
4764+ .unwrap_or(rest);
4765+ format!("{prefix}{base}")
4766+ };
45774767 let mut entries: Vec<String> = cg
45784768 .overridable_constants
45794769 .iter()
@@ -4581,9 +4771,9 @@ impl<M: cranelift_module::Module> Codegen<M> {
45814771 .map(|(name, ty)| {
45824772 if let Some(def) = cg.overridable_defaults.get(name) {
45834773 let escaped = def.replace('\\', "\\\\").replace(',', "\\c");
4584− format!("{}:{}:={}", name, ty, escaped)
4774+ format!("{}:{}:={}", name, display_ty(ty), escaped)
45854775 } else {
4586− format!("{}:{}", name, ty)
4776+ format!("{}:{}", name, display_ty(ty))
45874777 }
45884778 })
45894779 .collect();
@@ -6352,22 +6542,27 @@ ast::ExprKind::Atomic(inner) => {
63526542 // optional args use the default; required args (`#name _`)
63536543 // abort with an error.
63546544 let type_str = self.overridable_constants.get(name.as_str()).cloned();
6355− let type_tag: Option<i64> = type_str.as_deref().and_then(|s| match s {
6356− "Int" => Some(0),
6357− "Float" => Some(1),
6358− "Text" => Some(2),
6359− "Bool" => Some(3),
6360− "Maybe Int" => Some(4),
6361− "Maybe Float" => Some(5),
6362− "Maybe Text" => Some(6),
6363− "Maybe Bool" => Some(7),
6364− _ => None,
6365− });
6366− if let Some(type_tag) = type_tag {
6367− let (name_ptr, name_len) = self.string_ptr(builder, name);
6368− let tag = builder.ins().iconst(types::I32, type_tag);
6369− let override_val =
6370− self.call_rt(builder, "knot_override_lookup", &[name_ptr, name_len, tag]);
6545+ if let Some(type_str) = type_str {
6546+ let (override_val, _) = self.emit_override_lookup(builder, name, &type_str);
6547+
6548+ // Refined-type override: run the refinement predicate(s)
6549+ // on the CLI value before it reaches the program.
6550+ if let Some(refined_name) = type_str
6551+ .strip_prefix("refined:")
6552+ .or_else(|| type_str.strip_prefix("Maybe refined:"))
6553+ {
6554+ let refined_name = refined_name.to_string();
6555+ let is_maybe = type_str.starts_with("Maybe ");
6556+ self.emit_refined_override_check(
6557+ builder,
6558+ name,
6559+ &refined_name,
6560+ override_val,
6561+ is_maybe,
6562+ env,
6563+ db,
6564+ );
6565+ }
63716566
63726567 match default {
63736568 Some(d) => {
@@ -6420,14 +6615,14 @@ ast::ExprKind::Atomic(inner) => {
64206615 }
64216616 }
64226617 } else {
6423− // Non-scalar type — no CLI parser. Fall through to the
6618+ // Non-overridable type — no CLI parser. Fall through to the
64246619 // default (or error for required args).
64256620 match default {
64266621 Some(d) => self.compile_expr(builder, d.as_ref(), env, db),
64276622 None => {
64286623 self.diagnostics.push(knot::diagnostic::Diagnostic::error(
64296624 format!(
6430− "argument `#{}` has a non-scalar type and cannot be set from the command line",
6625+ "argument `#{}` has a type that cannot be set from the command line",
64316626 name
64326627 ),
64336628 ));
@@ -13235,6 +13430,191 @@ ast::ExprKind::Atomic(inner) => {
1323513430 /// clean compile error diagnostic instead of panicking, so a mistyped
1323613431 /// `--flag=...` reports like any other compile error rather than dumping a
1323713432 /// stack trace.
13433+ /// Map an override type descriptor to the legacy scalar tag (0–7) used by
13434+ /// `knot_override_lookup`. Extended descriptors (`Bytes`, `Uuid`,
13435+ /// `Rel <scalar>`, `refined:*`, `enum:*`, and `Maybe` of those) have no
13436+ /// tag — they go through `knot_override_lookup_ex` instead.
13437+ fn override_scalar_tag(type_str: &str) -> Option<i64> {
13438+ Some(match type_str {
13439+ "Int" => 0,
13440+ "Float" => 1,
13441+ "Text" => 2,
13442+ "Bool" => 3,
13443+ "Maybe Int" => 4,
13444+ "Maybe Float" => 5,
13445+ "Maybe Text" => 6,
13446+ "Maybe Bool" => 7,
13447+ _ => return None,
13448+ })
13449+ }
13450+
13451+ /// Expand `enum:<Name>` / `refined:<Name>` descriptors (and their `Maybe `
13452+ /// variants) to the form the runtime parses: `enum:<Name>(<Ctor>,…)` and
13453+ /// `refined:<Name>(<base-scalar>)`. Other descriptors pass through
13454+ /// unchanged.
13455+ fn override_runtime_descriptor(&self, type_str: &str) -> String {
13456+ let expand = |rest: &str| -> Option<String> {
13457+ if let Some(name) = rest.strip_prefix("enum:") {
13458+ let ctors = self.override_adt_ctors.get(name)?;
13459+ return Some(format!("enum:{}({})", name, ctors.join(",")));
13460+ }
13461+ if let Some(name) = rest.strip_prefix("refined:") {
13462+ let base = self.refined_base_scalar(name)?;
13463+ return Some(format!("refined:{}({})", name, base));
13464+ }
13465+ None
13466+ };
13467+ if let Some(expanded) = expand(type_str) {
13468+ return expanded;
13469+ }
13470+ if let Some(rest) = type_str.strip_prefix("Maybe ")
13471+ && let Some(expanded) = expand(rest)
13472+ {
13473+ return format!("Maybe {expanded}");
13474+ }
13475+ type_str.to_string()
13476+ }
13477+
13478+ /// Walk a refined type's base chain to its underlying scalar (`Int`,
13479+ /// `Float`, `Text`, `Bool`) — the shape the CLI value is parsed as.
13480+ /// Resolves through the resolved-alias map (`type_aliases`), which holds
13481+ /// every alias (including refined ones, mapped to their resolved base).
13482+ fn refined_base_scalar(&self, name: &str) -> Option<&str> {
13483+ let mut cur: &str = name;
13484+ loop {
13485+ // `cur` must be a refined alias; its base is the resolved alias
13486+ // target (another refined name, or the scalar itself).
13487+ let resolved = self.type_aliases.get(cur)?;
13488+ match resolved {
13489+ ResolvedType::Int => return Some("Int"),
13490+ ResolvedType::Float => return Some("Float"),
13491+ ResolvedType::Text => return Some("Text"),
13492+ ResolvedType::Bool => return Some("Bool"),
13493+ ResolvedType::Named(next) => cur = next.as_str(),
13494+ _ => return None,
13495+ }
13496+ }
13497+ }
13498+
13499+ /// Emit the CLI-override lookup for a constant/arg. Returns
13500+ /// `(value, is_ex)`: `value` is null when the flag is absent; `is_ex`
13501+ /// marks the extended descriptor path (parsed by
13502+ /// `knot_override_lookup_ex`, which handles Bytes/Uuid/Rel/enums/refined
13503+ /// bases). Refined-predicate checks are NOT emitted here — the caller
13504+ /// knows the value's provenance (default vs override) and checks only the
13505+ /// override.
13506+ fn emit_override_lookup(
13507+ &mut self,
13508+ builder: &mut FunctionBuilder,
13509+ name: &str,
13510+ type_str: &str,
13511+ ) -> (Value, bool) {
13512+ if let Some(tag) = Self::override_scalar_tag(type_str) {
13513+ let (name_ptr, name_len) = self.string_ptr(builder, name);
13514+ let tag = builder.ins().iconst(types::I32, tag);
13515+ let v = self.call_rt(builder, "knot_override_lookup", &[name_ptr, name_len, tag]);
13516+ (v, false)
13517+ } else {
13518+ let desc = self.override_runtime_descriptor(type_str);
13519+ let (name_ptr, name_len) = self.string_ptr(builder, name);
13520+ let (desc_ptr, desc_len) = self.string_ptr(builder, &desc);
13521+ let v = self.call_rt(
13522+ builder,
13523+ "knot_override_lookup_ex",
13524+ &[name_ptr, name_len, desc_ptr, desc_len],
13525+ );
13526+ (v, true)
13527+ }
13528+ }
13529+
13530+ /// Emit the startup refinement check for a refined-type CLI override:
13531+ /// every predicate in the type's refinement chain must hold for the
13532+ /// override value, else the runtime prints an error naming the flag and
13533+ /// type and exits. `flag_name` is the CLI flag (without `--`). No-op when
13534+ /// the override value is null (flag absent — the default was already
13535+ /// checked at compile time) or when the value arrived as
13536+ /// `Maybe.Nothing {}` (a None payload carries no refined scalar).
13537+ fn emit_refined_override_check(
13538+ &mut self,
13539+ builder: &mut FunctionBuilder,
13540+ flag_name: &str,
13541+ type_name: &str,
13542+ override_val: Value,
13543+ is_maybe: bool,
13544+ env: &mut Env,
13545+ db: Value,
13546+ ) {
13547+ // Collect the predicate chain (stacked refinements conjoin) by walking
13548+ // refined bases: `type Age = Nat where …` checks Nat's preds too. The
13549+ // chain walks the AST alias map (`alias_ast`): a refined alias's base
13550+ // is the AST type it wraps (`Nat`, `Int 1`), so stepping through
13551+ // `TypeKind::Named` bases visits each stacked refinement.
13552+ let mut preds: Vec<ast::Expr> = Vec::new();
13553+ let mut cur: String = type_name.to_string();
13554+ loop {
13555+ let Some(pred) = self.refined_types.get(&cur) else { break };
13556+ preds.push(pred.clone());
13557+ let Some(ast_ty) = self.alias_ast.get(&cur) else { break };
13558+ let ast::TypeKind::Refined { base, .. } = &ast_ty.node else {
13559+ break;
13560+ };
13561+ let ast::TypeKind::Named(n) = &base.node else { break };
13562+ cur = n.clone();
13563+ }
13564+ if preds.is_empty() {
13565+ return;
13566+ }
13567+
13568+ // Skip the check when the flag is absent (null) — the default value
13569+ // was checked at compile time. All blocks are sealed: each has a
13570+ // fixed predecessor set established below.
13571+ let present_block = builder.create_block();
13572+ let done_block = builder.create_block();
13573+ let is_null = builder.ins().icmp_imm(IntCC::Equal, override_val, 0);
13574+ builder
13575+ .ins()
13576+ .brif(is_null, done_block, &[], present_block, &[]);
13577+
13578+ builder.switch_to_block(present_block);
13579+ builder.seal_block(present_block);
13580+ // A Maybe-wrapped override (`Just {value: v}`) must be unwrapped
13581+ // before the predicate sees it; a `Nothing` override skips the check.
13582+ let check_val = if is_maybe {
13583+ let just_block = builder.create_block();
13584+ let unwrapped = self.call_rt(
13585+ builder,
13586+ "knot_override_unwrap_just",
13587+ &[override_val],
13588+ );
13589+ // knot_override_unwrap_just returns null for Nothing, else payload.
13590+ let is_none = builder.ins().icmp_imm(IntCC::Equal, unwrapped, 0);
13591+ builder
13592+ .ins()
13593+ .brif(is_none, done_block, &[], just_block, &[]);
13594+ builder.switch_to_block(just_block);
13595+ builder.seal_block(just_block);
13596+ unwrapped
13597+ } else {
13598+ override_val
13599+ };
13600+
13601+ let (flag_ptr, flag_len) = self.string_ptr(builder, flag_name);
13602+ let (ty_ptr, ty_len) = self.string_ptr(builder, type_name);
13603+ for pred in &preds {
13604+ let pred_fn = self.compile_expr(builder, pred, env, db);
13605+ let res = self.call_rt(builder, "knot_value_call", &[db, pred_fn, check_val]);
13606+ self.call_rt_void(
13607+ builder,
13608+ "knot_override_check_refined",
13609+ &[res, flag_ptr, flag_len, ty_ptr, ty_len, check_val],
13610+ );
13611+ }
13612+ builder.ins().jump(done_block, &[]);
13613+
13614+ builder.switch_to_block(done_block);
13615+ builder.seal_block(done_block);
13616+ }
13617+
1323813618 fn emit_override_literal(
1323913619 &mut self,
1324013620 builder: &mut FunctionBuilder,
@@ -13252,31 +13632,35 @@ ast::ExprKind::Atomic(inner) => {
1325213632 let unit = self.call_rt(builder, "knot_value_unit", &[]);
1325313633 return self.call_rt(builder, "knot_value_constructor", &[tag_ptr, tag_len, unit]);
1325413634 }
13255− // Helper to report a bad override value as a compile error and emit a
13256− // harmless unit value so codegen can keep going (the error aborts the
13257− // build before linking, via `compile()`'s diagnostics check).
13258− let mut bad = |expected: &str| -> Value {
13259− self.diagnostics
13260− .push(knot::diagnostic::Diagnostic::error(format!(
13261− "override value '{}' is not a valid {} for this constant",
13262− val_str, expected
13263− )));
13264− self.call_rt(builder, "knot_value_unit", &[])
13265− };
13635+ // Report a bad override value as a compile error and emit a harmless
13636+ // unit value so codegen can keep going (the error aborts the build
13637+ // before linking, via `compile()`'s diagnostics check). A macro, not a
13638+ // closure: a closure would hold `self`/`builder` borrowed across the
13639+ // whole match.
13640+ macro_rules! bad {
13641+ ($expected:expr) => {{
13642+ self.diagnostics
13643+ .push(knot::diagnostic::Diagnostic::error(format!(
13644+ "override value '{}' is not a valid {} for this constant",
13645+ val_str, $expected
13646+ )));
13647+ self.call_rt(builder, "knot_value_unit", &[])
13648+ }};
13649+ }
1326613650 let inner = match type_str {
1326713651 "Int" | "Maybe Int" => match val_str.parse::<i64>() {
1326813652 Ok(n) => {
1326913653 let n_val = builder.ins().iconst(types::I64, n);
1327013654 self.call_rt(builder, "knot_value_int", &[n_val])
1327113655 }
13272− Err(_) => bad("Int"),
13656+ Err(_) => bad!("Int"),
1327313657 },
1327413658 "Float" | "Maybe Float" => match val_str.parse::<f64>() {
1327513659 Ok(n) => {
1327613660 let n_val = builder.ins().f64const(n);
1327713661 self.call_rt(builder, "knot_value_float", &[n_val])
1327813662 }
13279− Err(_) => bad("Float"),
13663+ Err(_) => bad!("Float"),
1328013664 },
1328113665 "Text" | "Maybe Text" => {
1328213666 let (ptr, len) = self.string_ptr(builder, val_str);
@@ -13296,9 +13680,124 @@ ast::ExprKind::Atomic(inner) => {
1329613680 let val = builder.ins().iconst(types::I32, 0);
1329713681 self.call_rt(builder, "knot_value_bool", &[val])
1329813682 }
13299− _ => bad("Bool (expected true/false)"),
13683+ _ => bad!("Bool (expected true/false)"),
13684+ }
13685+ }
13686+ "Bytes" | "Maybe Bytes" => {
13687+ // base64url-decode at compile time, emit as a Bytes value via
13688+ // the runtime's `bytesFromHex`-independent constructor: encode
13689+ // the decoded bytes back to hex and call knot_bytes_from_hex
13690+ // on it (always `Just` for valid hex).
13691+ match decode_base64url_compile(val_str) {
13692+ Some(bytes) => {
13693+ let hex: String =
13694+ bytes.iter().map(|b| format!("{:02x}", b)).collect();
13695+ let (ptr, len) = self.string_ptr(builder, &hex);
13696+ let text = self.call_rt(builder, "knot_value_text", &[ptr, len]);
13697+ let just = self.call_rt(builder, "knot_bytes_from_hex", &[text]);
13698+ // Unwrap the guaranteed-Just to the bare Bytes.
13699+ self.call_rt(builder, "knot_override_unwrap_just", &[just])
13700+ }
13701+ None => bad!("base64url-encoded Bytes"),
1330013702 }
1330113703 }
13704+ "Uuid" | "Maybe Uuid" => {
13705+ let valid = val_str.len() == 36
13706+ && val_str.bytes().enumerate().all(|(i, b)| {
13707+ if i == 8 || i == 13 || i == 18 || i == 23 {
13708+ b == b'-'
13709+ } else {
13710+ b.is_ascii_hexdigit()
13711+ }
13712+ });
13713+ if valid {
13714+ let (ptr, len) = self.string_ptr(builder, val_str);
13715+ let slot = self.text_literal_slot(builder, val_str);
13716+ self.call_rt(builder, "knot_value_text_intern", &[ptr, len, slot])
13717+ } else {
13718+ bad!("Uuid (xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx)")
13719+ }
13720+ }
13721+ s if s.starts_with("enum:") || s.starts_with("Maybe enum:") => {
13722+ let ty_name = s
13723+ .strip_prefix("Maybe ")
13724+ .unwrap_or(s)
13725+ .trim_start_matches("enum:");
13726+ let known = self
13727+ .override_adt_ctors
13728+ .get(ty_name)
13729+ .map(|cs| cs.iter().any(|c| c == val_str))
13730+ .unwrap_or(false);
13731+ if known {
13732+ let (tag_ptr, tag_len) = self.string_ptr(builder, val_str);
13733+ let unit = self.call_rt(builder, "knot_value_unit", &[]);
13734+ self.call_rt(builder, "knot_value_constructor", &[tag_ptr, tag_len, unit])
13735+ } else {
13736+ let ctors = self
13737+ .override_adt_ctors
13738+ .get(ty_name)
13739+ .map(|c| c.join(", "))
13740+ .unwrap_or_default();
13741+ bad!(&format!("one of: {}", ctors))
13742+ }
13743+ }
13744+ s if s.starts_with("Rel ") || s.starts_with("Maybe Rel ") => {
13745+ // Comma-separated elements, parsed per the element scalar.
13746+ let elem = s
13747+ .trim_start_matches("Maybe ")
13748+ .trim_start_matches("Rel ")
13749+ .to_string();
13750+ let rel = self.call_rt(builder, "knot_relation_empty", &[]);
13751+ for piece in val_str.split(',') {
13752+ if piece.is_empty() {
13753+ continue;
13754+ }
13755+ let elem_val = match elem.as_str() {
13756+ "Int" => match piece.parse::<i64>() {
13757+ Ok(n) => {
13758+ let n_val = builder.ins().iconst(types::I64, n);
13759+ self.call_rt(builder, "knot_value_int", &[n_val])
13760+ }
13761+ Err(_) => bad!("Int element"),
13762+ },
13763+ "Float" => match piece.parse::<f64>() {
13764+ Ok(n) => {
13765+ let n_val = builder.ins().f64const(n);
13766+ self.call_rt(builder, "knot_value_float", &[n_val])
13767+ }
13768+ Err(_) => bad!("Float element"),
13769+ },
13770+ "Text" => {
13771+ let (ptr, len) = self.string_ptr(builder, piece);
13772+ self.call_rt(builder, "knot_value_text", &[ptr, len])
13773+ }
13774+ "Bool" => match piece {
13775+ "true" | "True" | "1" => {
13776+ let v = builder.ins().iconst(types::I32, 1);
13777+ self.call_rt(builder, "knot_value_bool", &[v])
13778+ }
13779+ "false" | "False" | "0" => {
13780+ let v = builder.ins().iconst(types::I32, 0);
13781+ self.call_rt(builder, "knot_value_bool", &[v])
13782+ }
13783+ _ => bad!("Bool element (expected true/false)"),
13784+ },
13785+ _ => bad!("scalar element"),
13786+ };
13787+ self.call_rt_void(builder, "knot_relation_push", &[rel, elem_val]);
13788+ }
13789+ rel
13790+ }
13791+ s if s.starts_with("refined:") || s.starts_with("Maybe refined:") => {
13792+ // Parse as the base scalar; the compile-time predicate check
13793+ // is a TODO (the CLI path checks at startup).
13794+ self.diagnostics
13795+ .push(knot::diagnostic::Diagnostic::error(format!(
13796+ "compile-time overrides of refined-type constants ('{}') are not yet supported; use a CLI flag instead",
13797+ type_str
13798+ )));
13799+ self.call_rt(builder, "knot_value_unit", &[])
13800+ }
1330213801 _ => {
1330313802 self.diagnostics
1330413803 .push(knot::diagnostic::Diagnostic::error(format!(
crates/knot-jit/tests/cli_overrides.rsnew file+416
@@ -0,0 +1,416 @@
1+//! CLI argument overrides (`#name` declarations and overridable top-level
2+//! constants) for the extended type set: refined scalars (with startup
3+//! predicate checks), enums (all-nullary ADTs), Bytes (URL-safe base64),
4+//! Uuid, and relations of scalars.
5+
6+mod e2e;
7+
8+use e2e::{TempDir, build_in_dir};
9+use std::process::Command;
10+
11+/// Build `src` once and run it with each argv; return (stdout, stderr, code)
12+/// per run.
13+fn run_with_args(dir: &TempDir, name: &str, args: &[&str]) -> (String, String, i32) {
14+ let out = Command::new(dir.path().join(name))
15+ .args(args)
16+ .current_dir(dir.path())
17+ .output()
18+ .expect("failed to run compiled program");
19+ (
20+ String::from_utf8_lossy(&out.stdout).to_string(),
21+ String::from_utf8_lossy(&out.stderr).to_string(),
22+ out.status.code().unwrap_or(-1),
23+ )
24+}
25+
26+fn build(dir: &TempDir, name: &str, src: &str) {
27+ build_in_dir(name, src, dir.path());
28+}
29+
30+// ── Refined scalars ─────────────────────────────────────────────────────────
31+
32+#[test]
33+fn refined_arg_valid_override() {
34+ let dir = TempDir::fresh("refined_ok");
35+ build(
36+ &dir,
37+ "refined_ok",
38+ r#"with {
39+Port Int 1 \x -> (x >= 0) && (x <= 65535)
40+Port #port 8080
41+}
42+(|
43+ base.println port
44+ yield {})"#,
45+ );
46+ let (out, err, code) = run_with_args(&dir, "refined_ok", &["--port=1234"]);
47+ assert_eq!(code, 0, "stderr: {err}");
48+ assert_eq!(out.trim_end(), "1234\n{}");
49+}
50+
51+#[test]
52+fn refined_arg_default_unchanged() {
53+ let dir = TempDir::fresh("refined_default");
54+ build(
55+ &dir,
56+ "refined_default",
57+ r#"with {
58+Port Int 1 \x -> (x >= 0) && (x <= 65535)
59+Port #port 8080
60+}
61+(|
62+ base.println port
63+ yield {})"#,
64+ );
65+ let (out, err, code) = run_with_args(&dir, "refined_default", &[]);
66+ assert_eq!(code, 0, "stderr: {err}");
67+ assert_eq!(out.trim_end(), "8080\n{}");
68+}
69+
70+#[test]
71+fn refined_arg_predicate_violation_aborts() {
72+ let dir = TempDir::fresh("refined_bad");
73+ build(
74+ &dir,
75+ "refined_bad",
76+ r#"with {
77+Port Int 1 \x -> (x >= 0) && (x <= 65535)
78+Port #port 8080
79+}
80+(|
81+ base.println port
82+ yield {})"#,
83+ );
84+ let (out, err, code) = run_with_args(&dir, "refined_bad", &["--port=70000"]);
85+ assert_ne!(code, 0, "expected failure, stdout: {out}");
86+ assert!(
87+ err.contains("--port") && err.contains("Port"),
88+ "stderr should name the flag and type, got: {err}"
89+ );
90+}
91+
92+#[test]
93+fn refined_arg_stacked_predicates() {
94+ // A refinement of a refinement conjoins both predicates.
95+ let dir = TempDir::fresh("refined_stacked");
96+ build(
97+ &dir,
98+ "refined_stacked",
99+ r#"with {
100+Nat Int 1 \x -> x >= 0
101+Port Nat \x -> x <= 65535
102+Port #port 8080
103+}
104+(|
105+ base.println port
106+ yield {})"#,
107+ );
108+ // Violates the inner (Nat) predicate: -1 < 0.
109+ let (_, err, code) = run_with_args(&dir, "refined_stacked", &["--port=-1"]);
110+ assert_ne!(code, 0, "expected Nat predicate failure, stderr: {err}");
111+ // Violates the outer (Port) predicate only.
112+ let (_, err, code) = run_with_args(&dir, "refined_stacked", &["--port=99999"]);
113+ assert_ne!(code, 0, "expected Port predicate failure, stderr: {err}");
114+ // Satisfies both.
115+ let (out, err, code) = run_with_args(&dir, "refined_stacked", &["--port=80"]);
116+ assert_eq!(code, 0, "stderr: {err}");
117+ assert_eq!(out.trim_end(), "80\n{}");
118+}
119+
120+#[test]
121+fn refined_text_arg() {
122+ let dir = TempDir::fresh("refined_text");
123+ build(
124+ &dir,
125+ "refined_text",
126+ r#"with {
127+Slug Text \s -> (base.length s) > 0
128+Slug #name _
129+}
130+(|
131+ base.println name
132+ yield {})"#,
133+ );
134+ let (out, err, code) = run_with_args(&dir, "refined_text", &["--name=hello"]);
135+ assert_eq!(code, 0, "stderr: {err}");
136+ assert_eq!(out.trim_end(), "\"hello\"\n{}");
137+ let (_, err, code) = run_with_args(&dir, "refined_text", &["--name="]);
138+ assert_ne!(code, 0, "expected empty-slug rejection, stderr: {err}");
139+}
140+
141+// ── Enums (all-nullary ADTs) ────────────────────────────────────────────────
142+
143+#[test]
144+fn enum_arg_valid_constructor() {
145+ let dir = TempDir::fresh("enum_ok");
146+ build(
147+ &dir,
148+ "enum_ok",
149+ r#"with {
150+Mode Dev {} Prod {}
151+Mode #mode (Mode.Dev {})
152+}
153+(|
154+ (? (mode)
155+ Mode.Dev {} base.println "dev"
156+ Mode.Prod {} base.println "prod")
157+ yield {})"#,
158+ );
159+ let (out, err, code) = run_with_args(&dir, "enum_ok", &["--mode=Prod"]);
160+ assert_eq!(code, 0, "stderr: {err}");
161+ assert_eq!(out.trim_end(), "\"prod\"\n{}");
162+}
163+
164+#[test]
165+fn enum_arg_default() {
166+ let dir = TempDir::fresh("enum_default");
167+ build(
168+ &dir,
169+ "enum_default",
170+ r#"with {
171+Mode Dev {} Prod {}
172+Mode #mode (Mode.Dev {})
173+}
174+(|
175+ (? (mode)
176+ Mode.Dev {} base.println "dev"
177+ Mode.Prod {} base.println "prod")
178+ yield {})"#,
179+ );
180+ let (out, err, code) = run_with_args(&dir, "enum_default", &[]);
181+ assert_eq!(code, 0, "stderr: {err}");
182+ assert_eq!(out.trim_end(), "\"dev\"\n{}");
183+}
184+
185+#[test]
186+fn enum_arg_unknown_constructor_aborts() {
187+ let dir = TempDir::fresh("enum_bad");
188+ build(
189+ &dir,
190+ "enum_bad",
191+ r#"with {
192+Mode Dev {} Prod {}
193+Mode #mode (Mode.Dev {})
194+}
195+(|
196+ (? (mode)
197+ Mode.Dev {} base.println "dev"
198+ Mode.Prod {} base.println "prod")
199+ yield {})"#,
200+ );
201+ let (_, err, code) = run_with_args(&dir, "enum_bad", &["--mode=Staging"]);
202+ assert_ne!(code, 0, "expected unknown-constructor failure");
203+ assert!(
204+ err.contains("Dev") && err.contains("Prod"),
205+ "stderr should list valid constructors, got: {err}"
206+ );
207+}
208+
209+// ── Bytes (URL-safe base64) ─────────────────────────────────────────────────
210+
211+#[test]
212+fn bytes_arg_valid() {
213+ let dir = TempDir::fresh("bytes_ok");
214+ build(
215+ &dir,
216+ "bytes_ok",
217+ r#"with {
218+Bytes #key (base.textToBytes "x")
219+}
220+(|
221+ base.println (base.bytesToHex key)
222+ yield {})"#,
223+ );
224+ // 0xde 0xad 0xbe 0xef in base64url is "3q2-7w" (no padding needed).
225+ let (out, err, code) = run_with_args(&dir, "bytes_ok", &["--key=3q2-7w"]);
226+ assert_eq!(code, 0, "stderr: {err}");
227+ assert_eq!(out.trim_end(), "\"deadbeef\"\n{}");
228+}
229+
230+#[test]
231+fn bytes_arg_rejects_standard_alphabet() {
232+ let dir = TempDir::fresh("bytes_std");
233+ build(
234+ &dir,
235+ "bytes_std",
236+ r#"with {
237+Bytes #key (base.textToBytes "x")
238+}
239+(|
240+ base.println (base.bytesToHex key)
241+ yield {})"#,
242+ );
243+ // '+' is standard base64, not URL-safe — must be rejected.
244+ let (_, err, code) = run_with_args(&dir, "bytes_std", &["--key=3q2+7w=="]);
245+ assert_ne!(code, 0, "expected standard-alphabet rejection, stderr: {err}");
246+}
247+
248+// ── Uuid ────────────────────────────────────────────────────────────────────
249+
250+#[test]
251+fn uuid_arg_valid() {
252+ let dir = TempDir::fresh("uuid_ok");
253+ build(
254+ &dir,
255+ "uuid_ok",
256+ r#"with {
257+Uuid #ident _
258+}
259+(|
260+ base.println ident
261+ yield {})"#,
262+ );
263+ let (out, err, code) = run_with_args(
264+ &dir,
265+ "uuid_ok",
266+ &["--ident=550e8400-e29b-41d4-a716-446655440000"],
267+ );
268+ assert_eq!(code, 0, "stderr: {err}");
269+ assert_eq!(
270+ out.trim_end(),
271+ "\"550e8400-e29b-41d4-a716-446655440000\"\n{}"
272+ );
273+}
274+
275+#[test]
276+fn uuid_arg_malformed_aborts() {
277+ let dir = TempDir::fresh("uuid_bad");
278+ build(
279+ &dir,
280+ "uuid_bad",
281+ r#"with {
282+Uuid #ident _
283+}
284+(|
285+ base.println ident
286+ yield {})"#,
287+ );
288+ let (_, err, code) = run_with_args(&dir, "uuid_bad", &["--ident=not-a-uuid"]);
289+ assert_ne!(code, 0, "expected malformed-uuid failure, stderr: {err}");
290+}
291+
292+// ── Relations of scalars ────────────────────────────────────────────────────
293+
294+#[test]
295+fn rel_text_repeated_flags() {
296+ let dir = TempDir::fresh("rel_repeat");
297+ build(
298+ &dir,
299+ "rel_repeat",
300+ r#"with {
301+Rel Text #tags []
302+}
303+(|
304+ base.println (base.count tags)
305+ yield {})"#,
306+ );
307+ let (out, err, code) = run_with_args(&dir, "rel_repeat", &["--tags=a", "--tags=b"]);
308+ assert_eq!(code, 0, "stderr: {err}");
309+ // count returns a one-element relation, shown as [2].
310+ assert_eq!(out.trim_end(), "[2]\n{}");
311+}
312+
313+#[test]
314+fn rel_text_comma_separated() {
315+ let dir = TempDir::fresh("rel_comma");
316+ build(
317+ &dir,
318+ "rel_comma",
319+ r#"with {
320+Rel Text #tags []
321+}
322+(|
323+ base.println (base.count tags)
324+ yield {})"#,
325+ );
326+ let (out, err, code) = run_with_args(&dir, "rel_comma", &["--tags=a,b,c"]);
327+ assert_eq!(code, 0, "stderr: {err}");
328+ assert_eq!(out.trim_end(), "[3]\n{}");
329+}
330+
331+#[test]
332+fn rel_text_empty_via_empty_value() {
333+ let dir = TempDir::fresh("rel_empty");
334+ build(
335+ &dir,
336+ "rel_empty",
337+ r#"with {
338+Rel Text #tags ["x" "y"]
339+}
340+(|
341+ base.println (base.count tags)
342+ yield {})"#,
343+ );
344+ let (out, err, code) = run_with_args(&dir, "rel_empty", &["--tags="]);
345+ assert_eq!(code, 0, "stderr: {err}");
346+ assert_eq!(out.trim_end(), "[0]\n{}");
347+}
348+
349+#[test]
350+fn rel_int_elements() {
351+ let dir = TempDir::fresh("rel_int");
352+ build(
353+ &dir,
354+ "rel_int",
355+ r#"with {
356+Rel (Int 1) #ports []
357+}
358+(|
359+ base.println (base.sum ports)
360+ yield {})"#,
361+ );
362+ let (out, err, code) = run_with_args(&dir, "rel_int", &["--ports=1,2", "--ports=3"]);
363+ assert_eq!(code, 0, "stderr: {err}");
364+ assert_eq!(out.trim_end(), "[6]\n{}");
365+}
366+
367+#[test]
368+fn rel_int_bad_element_aborts() {
369+ let dir = TempDir::fresh("rel_int_bad");
370+ build(
371+ &dir,
372+ "rel_int_bad",
373+ r#"with {
374+Rel (Int 1) #ports []
375+}
376+(|
377+ base.println (base.count ports)
378+ yield {})"#,
379+ );
380+ let (_, err, code) = run_with_args(&dir, "rel_int_bad", &["--ports=1,nope"]);
381+ assert_ne!(code, 0, "expected element parse failure, stderr: {err}");
382+}
383+
384+// ── Maybe variants of the new types ─────────────────────────────────────────
385+
386+#[test]
387+fn maybe_enum_override_and_nothing() {
388+ let dir = TempDir::fresh("maybe_enum");
389+ build(
390+ &dir,
391+ "maybe_enum",
392+ r#"with {
393+Mode Dev {} Prod {}
394+Maybe Mode #mode (Maybe.Nothing {})
395+}
396+(|
397+ (? (mode)
398+ Maybe.Just {value m} (? (m) Mode.Dev {} base.println "dev" Mode.Prod {} base.println "prod")
399+ Maybe.Nothing {} base.println "none")
400+ yield {})"#,
401+ );
402+ let (out, err, code) = run_with_args(&dir, "maybe_enum", &["--mode=Prod"]);
403+ assert_eq!(code, 0, "stderr: {err}");
404+ assert_eq!(out.trim_end(), "\"prod\"\n{}");
405+ let (out, err, code) = run_with_args(&dir, "maybe_enum", &["--mode=Nothing"]);
406+ assert_eq!(code, 0, "stderr: {err}");
407+ assert_eq!(out.trim_end(), "\"none\"\n{}");
408+}
409+
410+// ── Overridable top-level constants (non-# path) ────────────────────────────
411+//
412+// Compile-time (`knot build --name=value`) overrides of plain `with`-record
413+// constant fields are a separate, currently-disconnected mechanism: the
414+// `with`-field loop compiles the field's body inline rather than calling the
415+// global fn that would consult the override. That pre-existing gap is out of
416+// scope here; the runtime CLI path (`#`-args) is covered above.
crates/knot-runtime/src/lib.rs+348−58
@@ -3498,43 +3498,26 @@ fn debug_sql_params(sql: &str, params: &[rusqlite::types::Value]) {
34983498
34993499// ── CLI constant overrides ───────────────────────────────────────
35003500
3501−/// Look up a command-line override for a top-level constant.
3502−///
3503−/// Scans `std::env::args()` for `--{name}=value` or `--{name} value`.
3504−/// `type_tag`: 0=Int, 1=Float, 2=Text, 3=Bool, 4=Maybe Int, 5=Maybe Float, 6=Maybe Text, 7=Maybe Bool.
3505−/// Maybe tags automatically wrap the parsed value in `Just`.
3506−/// Returns a boxed `Value` if found, or null if absent.
3507−/// On parse error, prints a message and exits.
3508−#[unsafe(no_mangle)]
3509−pub extern "C-unwind" fn knot_override_lookup(
3510− name_ptr: *const u8,
3511− name_len: usize,
3512− type_tag: i32,
3513−) -> *mut Value {
3514− let name = unsafe { str_from_raw(name_ptr, name_len) };
3501+/// Scan argv for `--{name}=value`, `--{name} value`, or a bare `--{name}`
3502+/// (bools only). Returns the raw string value(s): every occurrence is
3503+/// collected so relation-typed overrides (`--tag=a --tag=b`) accumulate; for
3504+/// scalar overrides only the last occurrence is meaningful (last-wins,
3505+/// matching the previous single-slot behavior). `is_bool` enables the
3506+/// bare-flag-means-true two-arg disambiguation.
3507+fn override_flag_values(name: &str, is_bool: bool) -> Vec<String> {
35153508 let flag = format!("--{}", name);
3516−
3517− // Tags 4-7 are Maybe variants of 0-3
3518− let base_tag = if type_tag >= 4 {
3519− type_tag - 4
3520− } else {
3521− type_tag
3522− };
3523− let wrap_maybe = type_tag >= 4;
3524− let is_bool = base_tag == 3;
3525−
35263509 // Use args_os and skip non-UTF-8 entries: std::env::args() panics on
35273510 // non-UTF-8 argv, which would crash before any override lookup runs.
35283511 let args: Vec<String> = std::env::args_os()
35293512 .filter_map(|a| a.into_string().ok())
35303513 .collect();
3531− let mut value: Option<String> = None;
3514+ let mut values: Vec<String> = Vec::new();
35323515
35333516 let mut i = 1; // skip argv[0]
35343517 while i < args.len() {
35353518 if let Some(v) = args[i].strip_prefix(&flag) {
35363519 if let Some(v) = v.strip_prefix('=') {
3537− value = Some(v.to_string());
3520+ values.push(v.to_string());
35383521 } else if v.is_empty() {
35393522 if is_bool {
35403523 // Boolean flag. Consume the explicit two-arg form
@@ -3549,22 +3532,60 @@ pub extern "C-unwind" fn knot_override_lookup(
35493532 "true" | "True" | "1" | "false" | "False" | "0"
35503533 )
35513534 {
3552− value = Some(args[i + 1].clone());
3535+ values.push(args[i + 1].clone());
35533536 i += 1; // skip the consumed value token
35543537 } else {
3555− value = Some("true".to_string());
3538+ values.push("true".to_string());
35563539 }
35573540 } else if i + 1 < args.len() && !args[i + 1].starts_with("--") {
35583541 // --name value (two-arg form)
3559− value = Some(args[i + 1].clone());
3542+ values.push(args[i + 1].clone());
35603543 i += 1; // skip the consumed value token
35613544 }
35623545 }
35633546 }
35643547 i += 1;
35653548 }
3549+ values
3550+}
35663551
3567− let val_str = match value {
3552+/// Print an invalid-override error and exit, matching the existing
3553+/// `knot_override_lookup` failure shape.
3554+fn override_parse_error(name: &str, val: &str, expected: &str) -> ! {
3555+ eprintln!(
3556+ "Error: invalid value '{}' for --{} (expected {})",
3557+ val, name, expected
3558+ );
3559+ std::process::exit(1);
3560+}
3561+
3562+/// Look up a command-line override for a top-level constant.
3563+///
3564+/// Scans `std::env::args()` for `--{name}=value` or `--{name} value`.
3565+/// `type_tag`: 0=Int, 1=Float, 2=Text, 3=Bool, 4=Maybe Int, 5=Maybe Float, 6=Maybe Text, 7=Maybe Bool.
3566+/// Maybe tags automatically wrap the parsed value in `Just`.
3567+/// Returns a boxed `Value` if found, or null if absent.
3568+/// On parse error, prints a message and exits.
3569+#[unsafe(no_mangle)]
3570+pub extern "C-unwind" fn knot_override_lookup(
3571+ name_ptr: *const u8,
3572+ name_len: usize,
3573+ type_tag: i32,
3574+) -> *mut Value {
3575+ let name = unsafe { str_from_raw(name_ptr, name_len) };
3576+
3577+ // Tags 4-7 are Maybe variants of 0-3
3578+ let base_tag = if type_tag >= 4 {
3579+ type_tag - 4
3580+ } else {
3581+ type_tag
3582+ };
3583+ let wrap_maybe = type_tag >= 4;
3584+ let is_bool = base_tag == 3;
3585+
3586+ let values = override_flag_values(name, is_bool);
3587+ // Scalar override: last occurrence wins.
3588+ let val_str = match values.last() {
35683589 Some(v) => v,
35693590 None => return std::ptr::null_mut(),
35703591 };
@@ -3582,13 +3603,7 @@ pub extern "C-unwind" fn knot_override_lookup(
35823603 // Int
35833604 match val_str.parse::<i64>() {
35843605 Ok(n) => alloc_int(n),
3585− Err(_) => {
3586− eprintln!(
3587− "Error: invalid value '{}' for --{} (expected {})",
3588− val_str, name, type_name
3589− );
3590− std::process::exit(1);
3591− }
3606+ Err(_) => override_parse_error(name, val_str, type_name),
35923607 }
35933608 }
35943609 1 => {
@@ -3598,20 +3613,7 @@ pub extern "C-unwind" fn knot_override_lookup(
35983613 // path must too.
35993614 match val_str.parse::<f64>() {
36003615 Ok(n) if n.is_finite() => alloc_float(n),
3601− Ok(_) => {
3602− eprintln!(
3603− "Error: invalid value '{}' for --{} (expected a finite {})",
3604− val_str, name, type_name
3605− );
3606− std::process::exit(1);
3607− }
3608− Err(_) => {
3609− eprintln!(
3610− "Error: invalid value '{}' for --{} (expected {})",
3611− val_str, name, type_name
3612− );
3613− std::process::exit(1);
3614− }
3616+ _ => override_parse_error(name, val_str, type_name),
36153617 }
36163618 }
36173619 2 => {
@@ -3623,13 +3625,7 @@ pub extern "C-unwind" fn knot_override_lookup(
36233625 match val_str.as_str() {
36243626 "true" | "True" | "1" => encode_bool(true),
36253627 "false" | "False" | "0" => encode_bool(false),
3626− _ => {
3627− eprintln!(
3628− "Error: invalid value '{}' for --{} (expected true or false)",
3629− val_str, name
3630− );
3631− std::process::exit(1);
3632− }
3628+ _ => override_parse_error(name, val_str, "true or false"),
36333629 }
36343630 }
36353631 _ => return std::ptr::null_mut(),
@@ -3645,6 +3641,300 @@ pub extern "C-unwind" fn knot_override_lookup(
36453641 }
36463642}
36473643
3644+/// The override kinds that go through `knot_override_lookup_ex` rather than
3645+/// the tag-based `knot_override_lookup`. Parsed from the compiler-emitted
3646+/// descriptor string.
3647+enum ExKind<'a> {
3648+ Bytes,
3649+ Uuid,
3650+ /// Element scalar for `Rel <scalar>` ("Int" | "Float" | "Text" | "Bool").
3651+ Rel(&'a str),
3652+ /// All-nullary ADT: valid constructor names.
3653+ Enum(&'a [String]),
3654+ /// Refined scalar: the base scalar ("Int" | "Float" | "Text" | "Bool").
3655+ /// The predicate itself is checked in generated code, not here.
3656+ Refined(&'a str),
3657+}
3658+
3659+impl<'a> ExKind<'a> {
3660+ /// Whether the flag takes a bare `--name` form (bools only).
3661+ fn is_bool(&self) -> bool {
3662+ matches!(
3663+ self,
3664+ ExKind::Rel("Bool") | ExKind::Refined("Bool")
3665+ )
3666+ }
3667+
3668+ /// Expected-type wording for parse errors.
3669+ fn expected(&self) -> String {
3670+ match self {
3671+ ExKind::Bytes => "base64url-encoded Bytes".to_string(),
3672+ ExKind::Uuid => "a Uuid (xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx)".to_string(),
3673+ ExKind::Rel(e) => format!("a comma-separated list of {}", e),
3674+ ExKind::Enum(ctors) => format!("one of: {}", ctors.join(", ")),
3675+ ExKind::Refined(b) => b.to_string(),
3676+ }
3677+ }
3678+}
3679+
3680+/// Parse the compiler-emitted descriptor: `Bytes`, `Uuid`, `Rel <scalar>`,
3681+/// `enum:<Name>(<Ctor>,<Ctor>,…)`, `refined:<Name>(<base-scalar>)`, each
3682+/// optionally prefixed by `Maybe `.
3683+fn parse_ex_descriptor(desc: &str) -> Option<(bool, ExKind<'_>)> {
3684+ let (is_maybe, base) = match desc.strip_prefix("Maybe ") {
3685+ Some(rest) => (true, rest),
3686+ None => (false, desc),
3687+ };
3688+ let kind = match base {
3689+ "Bytes" => ExKind::Bytes,
3690+ "Uuid" => ExKind::Uuid,
3691+ _ if base.starts_with("Rel ") => ExKind::Rel(&base[4..]),
3692+ _ if base.starts_with("enum:") => {
3693+ let rest = &base[5..];
3694+ let open = rest.find('(')?;
3695+ let close = rest.rfind(')')?;
3696+ let ctor_str = &rest[open + 1..close];
3697+ let ctors: Vec<String> = if ctor_str.is_empty() {
3698+ Vec::new()
3699+ } else {
3700+ ctor_str.split(',').map(|s| s.to_string()).collect()
3701+ };
3702+ // Leak the parsed names: descriptors are compile-time constants
3703+ // parsed once per lookup, and the enum case needs an owned Vec
3704+ // anyway. Program-lifetime bounded, one per lookup call.
3705+ ExKind::Enum(Box::leak(ctors.into_boxed_slice()))
3706+ }
3707+ _ if base.starts_with("refined:") => {
3708+ let rest = &base[8..];
3709+ let open = rest.find('(')?;
3710+ let close = rest.rfind(')')?;
3711+ ExKind::Refined(&rest[open + 1..close])
3712+ }
3713+ _ => return None,
3714+ };
3715+ Some((is_maybe, kind))
3716+}
3717+
3718+/// Decode URL-safe base64 (RFC 4648 §5, `-`/`_`, padding optional). Returns
3719+/// None on any invalid character or length. Rejecting standard-alphabet
3720+/// characters (`+`/`/`) is deliberate: the CLI accepts base64url only.
3721+fn decode_base64url(s: &str) -> Option<Vec<u8>> {
3722+ if s.is_empty() {
3723+ return Some(Vec::new());
3724+ }
3725+ let mut out = Vec::with_capacity(s.len() * 3 / 4);
3726+ let mut buf: u32 = 0;
3727+ let mut nbits: u32 = 0;
3728+ for &b in s.as_bytes() {
3729+ let v: u32 = match b {
3730+ b'A'..=b'Z' => (b - b'A') as u32,
3731+ b'a'..=b'z' => (b - b'a' + 26) as u32,
3732+ b'0'..=b'9' => (b - b'0' + 52) as u32,
3733+ b'-' => 62,
3734+ b'_' => 63,
3735+ b'=' => continue, // tolerate padding anywhere; it's a no-op at the tail
3736+ _ => return None,
3737+ };
3738+ buf = (buf << 6) | v;
3739+ nbits += 6;
3740+ if nbits >= 8 {
3741+ nbits -= 8;
3742+ out.push((buf >> nbits) as u8);
3743+ }
3744+ }
3745+ // Leftover bits must be zero padding (a truncated char would be data).
3746+ if nbits >= 6 || (nbits > 0 && (buf & ((1 << nbits) - 1)) != 0) {
3747+ return None;
3748+ }
3749+ Some(out)
3750+}
3751+
3752+/// Parse one scalar element for a `Rel <scalar>` override.
3753+fn parse_rel_element(name: &str, val: &str, elem: &str) -> *mut Value {
3754+ match elem {
3755+ "Int" => match val.parse::<i64>() {
3756+ Ok(n) => alloc_int(n),
3757+ Err(_) => override_parse_error(name, val, "Int"),
3758+ },
3759+ "Float" => match val.parse::<f64>() {
3760+ Ok(n) if n.is_finite() => alloc_float(n),
3761+ _ => override_parse_error(name, val, "Float"),
3762+ },
3763+ "Text" => alloc(Value::Text(Arc::from(val))),
3764+ "Bool" => match val {
3765+ "true" | "True" | "1" => encode_bool(true),
3766+ "false" | "False" | "0" => encode_bool(false),
3767+ _ => override_parse_error(name, val, "true or false"),
3768+ },
3769+ _ => override_parse_error(name, val, "a scalar"),
3770+ }
3771+}
3772+
3773+/// Extended command-line override lookup for types the tag-based
3774+/// `knot_override_lookup` can't express: `Bytes`, `Uuid`, relations of
3775+/// scalars, all-nullary ADTs (by constructor name), and refined scalars
3776+/// (parsed as the base scalar; the predicate check is emitted separately by
3777+/// codegen). `desc` is the compiler-emitted descriptor (see
3778+/// `parse_ex_descriptor`). Returns the parsed value, `Just`-wrapped for
3779+/// `Maybe` descriptors, or null when the flag is absent. Parse errors print
3780+/// and exit, matching `knot_override_lookup`.
3781+#[unsafe(no_mangle)]
3782+pub extern "C-unwind" fn knot_override_lookup_ex(
3783+ name_ptr: *const u8,
3784+ name_len: usize,
3785+ desc_ptr: *const u8,
3786+ desc_len: usize,
3787+) -> *mut Value {
3788+ let name = unsafe { str_from_raw(name_ptr, name_len) };
3789+ let desc = unsafe { str_from_raw(desc_ptr, desc_len) };
3790+ let Some((is_maybe, kind)) = parse_ex_descriptor(desc) else {
3791+ eprintln!("Error: internal: unknown override descriptor '{}'", desc);
3792+ std::process::exit(1);
3793+ };
3794+
3795+ let values = override_flag_values(name, kind.is_bool());
3796+
3797+ // A Maybe-wrapped flag explicitly set to "Nothing" means `Nothing {}`.
3798+ if is_maybe
3799+ && let Some(last) = values.last()
3800+ && last == "Nothing"
3801+ {
3802+ return make_nothing();
3803+ }
3804+
3805+ if let ExKind::Rel(elem) = &kind {
3806+ if values.is_empty() {
3807+ return std::ptr::null_mut();
3808+ }
3809+ // Each occurrence may be comma-separated; an empty value
3810+ // (`--tag=`) contributes no elements, so `--tag=` alone yields
3811+ // the empty relation.
3812+ let mut rows: Vec<*mut Value> = Vec::new();
3813+ for occ in &values {
3814+ for piece in occ.split(',') {
3815+ if piece.is_empty() {
3816+ continue;
3817+ }
3818+ rows.push(parse_rel_element(name, piece, elem));
3819+ }
3820+ }
3821+ let rel = alloc(Value::Relation(rows));
3822+ return if is_maybe { make_just(rel) } else { rel };
3823+ }
3824+
3825+ // Scalar-shaped kinds: last occurrence wins.
3826+ let val_str = match values.last() {
3827+ Some(v) => v,
3828+ None => return std::ptr::null_mut(),
3829+ };
3830+
3831+ let inner = match &kind {
3832+ ExKind::Bytes => match decode_base64url(val_str) {
3833+ Some(bytes) => alloc(Value::Bytes(Arc::from(bytes))),
3834+ None => override_parse_error(name, val_str, &kind.expected()),
3835+ },
3836+ ExKind::Uuid => {
3837+ // Canonical 8-4-4-4-12 hex form (36 chars), same rule the HTTP
3838+ // param path uses.
3839+ let valid = val_str.len() == 36
3840+ && val_str.as_bytes().iter().enumerate().all(|(i, &b)| {
3841+ if i == 8 || i == 13 || i == 18 || i == 23 {
3842+ b == b'-'
3843+ } else {
3844+ b.is_ascii_hexdigit()
3845+ }
3846+ });
3847+ if valid {
3848+ alloc(Value::Text(Arc::from(val_str.as_str())))
3849+ } else {
3850+ override_parse_error(name, val_str, &kind.expected())
3851+ }
3852+ }
3853+ ExKind::Enum(ctors) => {
3854+ if ctors.iter().any(|c| c == val_str) {
3855+ let tag = intern_str(val_str);
3856+ alloc(Value::Constructor(tag, alloc(Value::Unit)))
3857+ } else {
3858+ override_parse_error(name, val_str, &kind.expected())
3859+ }
3860+ }
3861+ ExKind::Refined(base) => {
3862+ // Parse as the base scalar; the refinement predicate is checked
3863+ // by generated code after this returns.
3864+ parse_rel_element(name, val_str, base)
3865+ }
3866+ ExKind::Rel(_) => unreachable!("handled above"),
3867+ };
3868+
3869+ if is_maybe {
3870+ make_just(inner)
3871+ } else {
3872+ inner
3873+ }
3874+}
3875+
3876+/// Unwrap the payload of a `Just {value: v}` override value for the refined
3877+/// check. Returns null for `Nothing {}` (no refined scalar to check).
3878+#[unsafe(no_mangle)]
3879+pub extern "C-unwind" fn knot_override_unwrap_just(v: *mut Value) -> *mut Value {
3880+ match unsafe { as_ref(v) } {
3881+ Value::Constructor(tag, payload) if &**tag == "Just" => {
3882+ match unsafe { as_ref(*payload) } {
3883+ Value::Record(fields) => fields
3884+ .iter()
3885+ .find(|f| &*f.name == "value")
3886+ .map(|f| f.value)
3887+ .unwrap_or(std::ptr::null_mut()),
3888+ _ => std::ptr::null_mut(),
3889+ }
3890+ }
3891+ _ => std::ptr::null_mut(),
3892+ }
3893+}
3894+
3895+/// Render a value for an error message — user-facing `show` shape for the
3896+/// scalars a refined override can carry, debug-ish for anything else.
3897+fn display_value(v: *mut Value) -> String {
3898+ if v.is_null() {
3899+ return "null".to_string();
3900+ }
3901+ match unsafe { as_ref(v) } {
3902+ Value::Int(n) => n.to_string(),
3903+ Value::Float(n) => n.to_string(),
3904+ Value::Text(s) => s.to_string(),
3905+ Value::Bool(b) => b.to_string(),
3906+ Value::Bytes(b) => format!("Bytes({} bytes)", b.len()),
3907+ _ => format!("<{}>", type_name(v)),
3908+ }
3909+}
3910+
3911+/// Gate for a refined-type CLI override: `pred_result` must be a true Bool.
3912+/// On failure, prints an error naming the flag, value, and refined type, and
3913+/// exits — a CLI-supplied value that violates the type's predicate is a
3914+/// startup error, not a recoverable condition.
3915+#[unsafe(no_mangle)]
3916+pub extern "C-unwind" fn knot_override_check_refined(
3917+ pred_result: *mut Value,
3918+ flag_ptr: *const u8,
3919+ flag_len: usize,
3920+ ty_ptr: *const u8,
3921+ ty_len: usize,
3922+ value: *mut Value,
3923+) {
3924+ let ok = matches!(unsafe { as_ref(pred_result) }, Value::Bool(true));
3925+ if !ok {
3926+ let flag = unsafe { str_from_raw(flag_ptr, flag_len) };
3927+ let ty = unsafe { str_from_raw(ty_ptr, ty_len) };
3928+ eprintln!(
3929+ "Error: value '{}' for --{} does not satisfy predicate for type '{}'",
3930+ display_value(value),
3931+ flag,
3932+ ty
3933+ );
3934+ std::process::exit(1);
3935+ }
3936+}
3937+
36483938/// Decode a default-value string emitted by the compiler.
36493939///
36503940/// The compiler escapes `\` as `\\` and `,` as `\c` so the entry