@@ -10,6 +10,7 @@
1010// without benefit since they are never called from safe Rust.
1111#![allow(clippy::not_unsafe_ptr_arg_deref)]
1212
13+pub mod db_dump;
1314pub mod log;
1415mod tui;
1516
@@ -3550,8 +3551,7 @@ fn debug_sql_params(sql: &str, params: &[rusqlite::types::Value]) {
35503551/// scalar overrides only the last occurrence is meaningful (last-wins,
35513552/// matching the previous single-slot behavior). `is_bool` enables the
35523553/// bare-flag-means-true two-arg disambiguation.
3553−fn override_flag_values(name: &str, is_bool: bool) -> Vec<String> {
3554− let flag = format!("--{}", name);
3554+fn override_flag_values(name: &str, is_bool: bool) -> Vec<String> { let flag = format!("--{}", name);
35553555 // Use args_os and skip non-UTF-8 entries: std::env::args() panics on
35563556 // non-UTF-8 argv, which would crash before any override lookup runs.
35573557 let args: Vec<String> = std::env::args_os()
@@ -3610,6 +3610,23 @@ fn override_parse_error(name: &str, val: &str, expected: &str) -> ! {
36103610/// Scans `std::env::args()` for `--{name}=value` or `--{name} value`.
36113611/// `type_tag`: 0=Int, 1=Float, 2=Text, 3=Bool, 4=Maybe Int, 5=Maybe Float, 6=Maybe Text, 7=Maybe Bool.
36123612/// Maybe tags automatically wrap the parsed value in `Just`.
3613+/// Forbid a CLI flag for a `#`-argument whose type isn't CLI-settable (a
3614+/// record, a function, a collection of non-scalars). When such an arg has a
3615+/// default, codegen routes here so a passed flag aborts loudly instead of
3616+/// silently falling through to the default. No-op when the flag is absent.
3617+#[unsafe(no_mangle)]
3618+pub extern "C-unwind" fn knot_override_forbid(name_ptr: *const u8, name_len: usize) -> *mut Value {
3619+ let name = unsafe { str_from_raw(name_ptr, name_len) };
3620+ if !override_flag_values(name, false).is_empty() {
3621+ eprintln!(
3622+ "Error: argument --{} has a type that cannot be set from the command line",
3623+ name
3624+ );
3625+ std::process::exit(1);
3626+ }
3627+ alloc(Value::Unit)
3628+}
3629+
36133630/// Returns a boxed `Value` if found, or null if absent.
36143631/// On parse error, prints a message and exits.
36153632#[unsafe(no_mangle)]
@@ -3687,14 +3704,47 @@ pub extern "C-unwind" fn knot_override_lookup(
36873704 }
36883705}
36893706
3707+/// The element type of a collection override (`Vec`/`Rel`/`List <elem>`).
3708+enum ElemKind<'a> {
3709+ /// A CLI-parseable scalar: "Int" | "Float" | "Text" | "Bool".
3710+ Scalar(&'a str),
3711+ Bytes,
3712+ Uuid,
3713+ /// All-nullary ADT: valid constructor names.
3714+ Enum(&'a [String]),
3715+ /// Refined element: the base scalar it parses as. The per-element
3716+ /// predicate check runs in generated code, not here.
3717+ Refined(&'a str),
3718+}
3719+
3720+impl<'a> ElemKind<'a> {
3721+ fn is_bool(&self) -> bool {
3722+ matches!(self, ElemKind::Scalar("Bool"))
3723+ }
3724+ fn expected(&self) -> String {
3725+ match self {
3726+ ElemKind::Scalar(s) => s.to_string(),
3727+ ElemKind::Bytes => "base64url-encoded Bytes".to_string(),
3728+ ElemKind::Uuid => "a Uuid".to_string(),
3729+ ElemKind::Enum(ctors) => format!("one of: {}", ctors.join(", ")),
3730+ ElemKind::Refined(base) => base.to_string(),
3731+ }
3732+ }
3733+}
3734+
36903735/// The override kinds that go through `knot_override_lookup_ex` rather than
36913736/// the tag-based `knot_override_lookup`. Parsed from the compiler-emitted
36923737/// descriptor string.
36933738enum ExKind<'a> {
36943739 Bytes,
36953740 Uuid,
3696− /// Element scalar for `Rel <scalar>` ("Int" | "Float" | "Text" | "Bool").
3697− Rel(&'a str),
3741+ /// Element type for `Rel <elem>`. A relation is a SET: rows are deduped.
3742+ Rel(ElemKind<'a>),
3743+ /// `Vec <elem>` — ordered, allows duplicates. Shares the relation
3744+ /// representation but skips dedup.
3745+ Vec(ElemKind<'a>),
3746+ /// `List <elem>` — a Cons/Nil chain.
3747+ List(ElemKind<'a>),
36983748 /// All-nullary ADT: valid constructor names.
36993749 Enum(&'a [String]),
37003750 /// Refined scalar: the base scalar ("Int" | "Float" | "Text" | "Bool").
@@ -3705,10 +3755,11 @@ enum ExKind<'a> {
37053755impl<'a> ExKind<'a> {
37063756 /// Whether the flag takes a bare `--name` form (bools only).
37073757 fn is_bool(&self) -> bool {
3708− matches!(
3709− self,
3710− ExKind::Rel("Bool") | ExKind::Refined("Bool")
3711− )
3758+ match self {
3759+ ExKind::Rel(e) | ExKind::Vec(e) | ExKind::List(e) => e.is_bool(),
3760+ ExKind::Refined(b) => *b == "Bool",
3761+ _ => false,
3762+ }
37123763 }
37133764
37143765 /// Expected-type wording for parse errors.
@@ -3716,7 +3767,9 @@ impl<'a> ExKind<'a> {
37163767 match self {
37173768 ExKind::Bytes => "base64url-encoded Bytes".to_string(),
37183769 ExKind::Uuid => "a Uuid (xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx)".to_string(),
3719− ExKind::Rel(e) => format!("a comma-separated list of {}", e),
3770+ ExKind::Rel(e) | ExKind::Vec(e) | ExKind::List(e) => {
3771+ format!("a comma-separated list of {}", e.expected())
3772+ }
37203773 ExKind::Enum(ctors) => format!("one of: {}", ctors.join(", ")),
37213774 ExKind::Refined(b) => b.to_string(),
37223775 }
@@ -3724,8 +3777,8 @@ impl<'a> ExKind<'a> {
37243777}
37253778
37263779/// Parse the compiler-emitted descriptor: `Bytes`, `Uuid`, `Rel <scalar>`,
3727−/// `enum:<Name>(<Ctor>,<Ctor>,…)`, `refined:<Name>(<base-scalar>)`, each
3728−/// optionally prefixed by `Maybe `.
3780+/// `Vec <scalar>`, `List <scalar>`, `enum:<Name>(<Ctor>,<Ctor>,…)`,
3781+/// `refined:<Name>(<base-scalar>)`, each optionally prefixed by `Maybe `.
37293782fn parse_ex_descriptor(desc: &str) -> Option<(bool, ExKind<'_>)> {
37303783 let (is_maybe, base) = match desc.strip_prefix("Maybe ") {
37313784 Some(rest) => (true, rest),
@@ -3734,7 +3787,9 @@ fn parse_ex_descriptor(desc: &str) -> Option<(bool, ExKind<'_>)> {
37343787 let kind = match base {
37353788 "Bytes" => ExKind::Bytes,
37363789 "Uuid" => ExKind::Uuid,
3737− _ if base.starts_with("Rel ") => ExKind::Rel(&base[4..]),
3790+ _ if base.starts_with("Rel ") => ExKind::Rel(parse_elem_descriptor(&base[4..])?),
3791+ _ if base.starts_with("Vec ") => ExKind::Vec(parse_elem_descriptor(&base[4..])?),
3792+ _ if base.starts_with("List ") => ExKind::List(parse_elem_descriptor(&base[5..])?),
37383793 _ if base.starts_with("enum:") => {
37393794 let rest = &base[5..];
37403795 let open = rest.find('(')?;
@@ -3761,6 +3816,36 @@ fn parse_ex_descriptor(desc: &str) -> Option<(bool, ExKind<'_>)> {
37613816 Some((is_maybe, kind))
37623817}
37633818
3819+/// Parse a collection element descriptor into an `ElemKind`: a bare scalar
3820+/// (`Int`/`Float`/`Text`/`Bool`), `Bytes`, `Uuid`, an enum (`enum:Name(Ctor,…)`),
3821+/// or a refined type (`refined:Name(base)`).
3822+fn parse_elem_descriptor(elem: &str) -> Option<ElemKind<'_>> {
3823+ match elem {
3824+ "Int" | "Float" | "Text" | "Bool" => Some(ElemKind::Scalar(elem)),
3825+ "Bytes" => Some(ElemKind::Bytes),
3826+ "Uuid" => Some(ElemKind::Uuid),
3827+ _ if elem.starts_with("enum:") => {
3828+ let rest = &elem[5..];
3829+ let open = rest.find('(')?;
3830+ let close = rest.rfind(')')?;
3831+ let ctor_str = &rest[open + 1..close];
3832+ let ctors: Vec<String> = if ctor_str.is_empty() {
3833+ Vec::new()
3834+ } else {
3835+ ctor_str.split(',').map(|s| s.to_string()).collect()
3836+ };
3837+ Some(ElemKind::Enum(Box::leak(ctors.into_boxed_slice())))
3838+ }
3839+ _ if elem.starts_with("refined:") => {
3840+ let rest = &elem[8..];
3841+ let open = rest.find('(')?;
3842+ let close = rest.rfind(')')?;
3843+ Some(ElemKind::Refined(&rest[open + 1..close]))
3844+ }
3845+ _ => None,
3846+ }
3847+}
3848+
37643849/// Decode URL-safe base64 (RFC 4648 §5, `-`/`_`, padding optional). Returns
37653850/// None on any invalid character or length. Rejecting standard-alphabet
37663851/// characters (`+`/`/`) is deliberate: the CLI accepts base64url only.
@@ -3795,24 +3880,56 @@ fn decode_base64url(s: &str) -> Option<Vec<u8>> {
37953880 Some(out)
37963881}
37973882
3798−/// Parse one scalar element for a `Rel <scalar>` override.
3799−fn parse_rel_element(name: &str, val: &str, elem: &str) -> *mut Value {
3883+/// Parse one element for a `Vec`/`Rel`/`List <elem>` override.
3884+fn parse_rel_element(name: &str, val: &str, elem: &ElemKind) -> *mut Value {
38003885 match elem {
3801− "Int" => match val.parse::<i64>() {
3802− Ok(n) => alloc_int(n),
3803− Err(_) => override_parse_error(name, val, "Int"),
3804− },
3805− "Float" => match val.parse::<f64>() {
3806− Ok(n) if n.is_finite() => alloc_float(n),
3807− _ => override_parse_error(name, val, "Float"),
3886+ ElemKind::Scalar(s) => match *s {
3887+ "Int" => match val.parse::<i64>() {
3888+ Ok(n) => alloc_int(n),
3889+ Err(_) => override_parse_error(name, val, "Int"),
3890+ },
3891+ "Float" => match val.parse::<f64>() {
3892+ Ok(n) if n.is_finite() => alloc_float(n),
3893+ _ => override_parse_error(name, val, "Float"),
3894+ },
3895+ "Text" => alloc(Value::Text(Arc::from(val))),
3896+ "Bool" => match val {
3897+ "true" | "True" | "1" => encode_bool(true),
3898+ "false" | "False" | "0" => encode_bool(false),
3899+ _ => override_parse_error(name, val, "true or false"),
3900+ },
3901+ _ => override_parse_error(name, val, "a scalar"),
38083902 },
3809− "Text" => alloc(Value::Text(Arc::from(val))),
3810− "Bool" => match val {
3811− "true" | "True" | "1" => encode_bool(true),
3812− "false" | "False" | "0" => encode_bool(false),
3813− _ => override_parse_error(name, val, "true or false"),
3903+ ElemKind::Bytes => match decode_base64url(val) {
3904+ Some(bytes) => alloc(Value::Bytes(Arc::from(bytes))),
3905+ None => override_parse_error(name, val, "base64url-encoded Bytes"),
38143906 },
3815− _ => override_parse_error(name, val, "a scalar"),
3907+ ElemKind::Uuid => {
3908+ let valid = val.len() == 36
3909+ && val.as_bytes().iter().enumerate().all(|(i, &b)| {
3910+ if i == 8 || i == 13 || i == 18 || i == 23 {
3911+ b == b'-'
3912+ } else {
3913+ b.is_ascii_hexdigit()
3914+ }
3915+ });
3916+ if valid {
3917+ alloc(Value::Text(Arc::from(val)))
3918+ } else {
3919+ override_parse_error(name, val, "a Uuid")
3920+ }
3921+ }
3922+ ElemKind::Enum(ctors) => {
3923+ if ctors.iter().any(|c| c == val) {
3924+ let tag = intern_str(val);
3925+ alloc(Value::Constructor(tag, alloc(Value::Unit)))
3926+ } else {
3927+ override_parse_error(name, val, &format!("one of: {}", ctors.join(", ")))
3928+ }
3929+ }
3930+ // A refined element parses as its base scalar; the per-element
3931+ // predicate check runs in generated code.
3932+ ElemKind::Refined(base) => parse_rel_element(name, val, &ElemKind::Scalar(base)),
38163933 }
38173934}
38183935
@@ -3840,21 +3957,13 @@ pub extern "C-unwind" fn knot_override_lookup_ex(
38403957
38413958 let values = override_flag_values(name, kind.is_bool());
38423959
3843− // A Maybe-wrapped flag explicitly set to "Nothing" means `Nothing {}`.
3844− if is_maybe
3845− && let Some(last) = values.last()
3846− && last == "Nothing"
3847− {
3848− return make_nothing();
3849− }
3850−
3851− if let ExKind::Rel(elem) = &kind {
3960+ if let ExKind::Rel(elem) | ExKind::Vec(elem) | ExKind::List(elem) = &kind {
38523961 if values.is_empty() {
38533962 return std::ptr::null_mut();
38543963 }
38553964 // Each occurrence may be comma-separated; an empty value
38563965 // (`--tag=`) contributes no elements, so `--tag=` alone yields
3857− // the empty relation.
3966+ // the empty collection.
38583967 let mut rows: Vec<*mut Value> = Vec::new();
38593968 for occ in &values {
38603969 for piece in occ.split(',') {
@@ -3864,8 +3973,23 @@ pub extern "C-unwind" fn knot_override_lookup_ex(
38643973 rows.push(parse_rel_element(name, piece, elem));
38653974 }
38663975 }
3867− let rel = alloc(Value::Relation(rows));
3868− return if is_maybe { make_just(rel) } else { rel };
3976+ let v = match &kind {
3977+ // A relation is a set: dedup.
3978+ ExKind::Rel(_) => alloc(Value::Relation(in_memory_dedup(rows))),
3979+ // A vec is ordered and allows duplicates: same representation, no
3980+ // dedup.
3981+ ExKind::Vec(_) => alloc(Value::Relation(rows)),
3982+ // A list is a Cons/Nil chain, built tail-first.
3983+ ExKind::List(_) => {
3984+ let mut acc = list_nil();
3985+ for row in rows.into_iter().rev() {
3986+ acc = list_cons(row, acc);
3987+ }
3988+ acc
3989+ }
3990+ _ => unreachable!(),
3991+ };
3992+ return if is_maybe { make_just(v) } else { v };
38693993 }
38703994
38713995 // Scalar-shaped kinds: last occurrence wins.
@@ -3907,9 +4031,11 @@ pub extern "C-unwind" fn knot_override_lookup_ex(
39074031 ExKind::Refined(base) => {
39084032 // Parse as the base scalar; the refinement predicate is checked
39094033 // by generated code after this returns.
3910− parse_rel_element(name, val_str, base)
4034+ parse_rel_element(name, val_str, &ElemKind::Scalar(base))
4035+ }
4036+ ExKind::Rel(_) | ExKind::Vec(_) | ExKind::List(_) => {
4037+ unreachable!("handled above")
39114038 }
3912− ExKind::Rel(_) => unreachable!("handled above"),
39134039 };
39144040
39154041 if is_maybe {
@@ -3984,6 +4110,74 @@ pub extern "C-unwind" fn knot_override_check_refined(
39844110 }
39854111}
39864112
4113+/// Apply a refined predicate to every element of a collection override
4114+/// (`Vec`/`Rel` — the relation representation — or `List`, a Cons/Nil chain).
4115+/// `pred_fn` is the compiled predicate (a function value); `db` is the live db
4116+/// handle for the call. Aborts naming the flag and type on the first element
4117+/// that fails. No-op when the override is null (flag absent — the default was
4118+/// already checked at compile time).
4119+#[unsafe(no_mangle)]
4120+pub extern "C-unwind" fn knot_override_check_refined_each(
4121+ db: *mut std::ffi::c_void,
4122+ coll: *mut Value,
4123+ pred_fn: *mut Value,
4124+ flag_ptr: *const u8,
4125+ flag_len: usize,
4126+ ty_ptr: *const u8,
4127+ ty_len: usize,
4128+) {
4129+ if coll.is_null() {
4130+ return;
4131+ }
4132+ let flag = unsafe { str_from_raw(flag_ptr, flag_len) }.to_string();
4133+ let ty = unsafe { str_from_raw(ty_ptr, ty_len) }.to_string();
4134+ let check = |elem: *mut Value| {
4135+ let res = knot_value_call(db, pred_fn, elem);
4136+ let ok = matches!(unsafe { as_ref(res) }, Value::Bool(true));
4137+ if !ok {
4138+ eprintln!(
4139+ "Error: value '{}' for --{} does not satisfy predicate for type '{}'",
4140+ display_value(elem),
4141+ flag,
4142+ ty
4143+ );
4144+ std::process::exit(1);
4145+ }
4146+ };
4147+ match unsafe { as_ref(coll) } {
4148+ Value::Relation(rows) => {
4149+ for &row in rows {
4150+ check(row);
4151+ }
4152+ }
4153+ // A List is a Cons/Nil chain: Cons {head, tail}.
4154+ Value::Constructor(..) => {
4155+ let mut cur = coll;
4156+ loop {
4157+ match unsafe { as_ref(cur) } {
4158+ Value::Constructor(tag, payload) if ctor_leaf(tag) == "Cons" => {
4159+ if let Value::Record(fields) = unsafe { as_ref(*payload) } {
4160+ let head = fields.iter().find(|f| &*f.name == "head").map(|f| f.value);
4161+ let tail = fields.iter().find(|f| &*f.name == "tail").map(|f| f.value);
4162+ if let Some(h) = head {
4163+ check(h);
4164+ }
4165+ match tail {
4166+ Some(t) => cur = t,
4167+ None => break,
4168+ }
4169+ } else {
4170+ break;
4171+ }
4172+ }
4173+ _ => break,
4174+ }
4175+ }
4176+ }
4177+ _ => {}
4178+ }
4179+}
4180+
39874181/// Decode a default-value string emitted by the compiler.
39884182///
39894183/// The compiler escapes `\` as `\\` and `,` as `\c` so the entry
@@ -11855,6 +12049,107 @@ type CompileImpl = unsafe extern "C" fn(
1185512049static COMPILE_IMPL: std::sync::atomic::AtomicPtr<c_void> =
1185612050 std::sync::atomic::AtomicPtr::new(std::ptr::null_mut());
1185712051
12052+/// The successful result of running a compile closure: the value plus the
12053+/// optional body-type descriptor and the snippet's declared relations, to hand
12054+/// back through the FFI out-params.
12055+pub struct CompileOutcome {
12056+ pub value: *mut Value,
12057+ pub ty: Option<String>,
12058+ pub relations: Vec<String>,
12059+}
12060+
12061+/// Null the three out-param pairs so a compile error leaves them well-defined.
12062+///
12063+/// # Safety
12064+/// Each non-null pointer must be valid for writes of its payload.
12065+pub unsafe fn compile_out_params_init(pairs: [(*mut *mut u8, *mut usize); 3]) {
12066+ unsafe {
12067+ for (p, l) in pairs {
12068+ if !p.is_null() {
12069+ *p = std::ptr::null_mut();
12070+ }
12071+ if !l.is_null() {
12072+ *l = 0;
12073+ }
12074+ }
12075+ }
12076+}
12077+
12078+/// Hand a string back as a malloc'd buffer (C ABI — the caller frees it).
12079+///
12080+/// # Safety
12081+/// `ptr`/`len`, if non-null, must be valid for writes.
12082+pub unsafe fn compile_write_out(s: &str, ptr: *mut *mut u8, len: *mut usize) {
12083+ if ptr.is_null() || len.is_null() {
12084+ return;
12085+ }
12086+ let buf = unsafe { libc::malloc(s.len().max(1)) as *mut u8 };
12087+ if buf.is_null() {
12088+ return;
12089+ }
12090+ unsafe {
12091+ std::ptr::copy_nonoverlapping(s.as_ptr(), buf, s.len());
12092+ *ptr = buf;
12093+ *len = s.len();
12094+ }
12095+}
12096+
12097+/// Run a `base.compile` compile+run closure behind the FFI boundary, containing
12098+/// any panic. `compile` is the compiler- and backend-specific work; it returns
12099+/// `Ok(Some(outcome))` on success, `Ok(None)` on a clean compile error, and any
12100+/// panic is caught here. On success the outcome's ty/relations are written to
12101+/// the out-params and the value returned; on error or panic the (already
12102+/// nulled) out-params get the message in `out_err` and null is returned, so the
12103+/// runtime surfaces a `Result.Err` instead of unwinding into the host.
12104+///
12105+/// This is the single containment point shared by every `knot_compile_impl`
12106+/// (the compiler binary's and the `knot-compile-rt` one a compiled program
12107+/// links), so the two cannot drift apart.
12108+///
12109+/// # Safety
12110+/// `db` must be a live db handle; the out-param pointers, if non-null, must be
12111+/// valid for writes of their payload. They must already be nulled via
12112+/// [`compile_out_params_init`] by the caller.
12113+#[allow(clippy::too_many_arguments)]
12114+pub unsafe fn run_compile_contained(
12115+ db: *mut c_void,
12116+ out_ty_ptr: *mut *mut u8,
12117+ out_ty_len: *mut usize,
12118+ out_rels_ptr: *mut *mut u8,
12119+ out_rels_len: *mut usize,
12120+ out_err_ptr: *mut *mut u8,
12121+ out_err_len: *mut usize,
12122+ compile: impl FnOnce(*mut c_void) -> Option<CompileOutcome>,
12123+) -> *mut Value {
12124+ // The raw pointers are !UnwindSafe, so assert it; the closure only reads
12125+ // them and the out-params were nulled by the caller, so no broken state is
12126+ // observable across a caught panic.
12127+ let r = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| compile(db)));
12128+ match r {
12129+ Ok(Some(outcome)) => {
12130+ if let Some(ty) = &outcome.ty {
12131+ unsafe { compile_write_out(ty, out_ty_ptr, out_ty_len) };
12132+ }
12133+ let csv = outcome.relations.join(",");
12134+ unsafe { compile_write_out(&csv, out_rels_ptr, out_rels_len) };
12135+ outcome.value
12136+ }
12137+ Ok(None) => std::ptr::null_mut(),
12138+ Err(payload) => {
12139+ let msg = payload
12140+ .downcast_ref::<&str>()
12141+ .map(|s| (*s).to_string())
12142+ .or_else(|| payload.downcast_ref::<String>().cloned())
12143+ .unwrap_or_else(|| "unknown panic".to_string());
12144+ unsafe {
12145+ compile_write_out(&format!("compile panicked: {msg}"), out_err_ptr, out_err_len)
12146+ };
12147+ std::ptr::null_mut()
12148+ }
12149+ }
12150+}
12151+
12152+
1185812153/// Registered by knot-compile-rt at program startup to install the compile
1185912154/// implementation. Called from the generated program's `main`.
1186012155#[unsafe(no_mangle)]
@@ -13893,6 +14188,73 @@ fn read_schema_cursor(conn: &rusqlite::Connection, name: &str) -> Option<(String
1389314188 .ok()
1389414189}
1389514190
14191+/// One migration step baked into the binary, recorded at startup for
14192+/// `db info`: the `from`/`to` descriptors and the migration fn source.
14193+#[derive(Clone, Debug)]
14194+pub struct MigrationStep {
14195+ pub step_idx: i64,
14196+ pub from_schema: String,
14197+ pub to_schema: String,
14198+ pub using_src: String,
14199+}
14200+
14201+/// The migration chain per source, recorded as the binary registers each step
14202+/// at startup. Lets `db info` show the full migration history (which lives in
14203+/// the binary, not the db) alongside the db's current cursor.
14204+static MIGRATION_CHAINS: std::sync::LazyLock<RwLock<HashMap<String, Vec<MigrationStep>>>> =
14205+ std::sync::LazyLock::new(|| RwLock::new(HashMap::new()));
14206+
14207+/// Record one migration step for `db info`. Idempotent per (source, step_idx)
14208+/// — re-registration (e.g. a rebuilt binary with the same chain) replaces it.
14209+fn register_migration_step(source: &str, step: MigrationStep) {
14210+ let mut chains = MIGRATION_CHAINS.write().unwrap_or_else(|e| e.into_inner());
14211+ let chain = chains.entry(source.to_string()).or_default();
14212+ match chain.iter_mut().find(|s| s.step_idx == step.step_idx) {
14213+ Some(existing) => *existing = step,
14214+ None => chain.push(step),
14215+ }
14216+ chain.sort_by_key(|s| s.step_idx);
14217+}
14218+
14219+/// `<program>` startup registers each baked-in migration step so `db info` can
14220+/// show the full per-source chain (which lives in the binary, not the db).
14221+/// `from`/`to` are the step's descriptors, `using_src` the migration fn source.
14222+#[unsafe(no_mangle)]
14223+pub extern "C-unwind" fn knot_register_migration_step(
14224+ source_ptr: *const u8,
14225+ source_len: usize,
14226+ step_idx: i64,
14227+ from_ptr: *const u8,
14228+ from_len: usize,
14229+ to_ptr: *const u8,
14230+ to_len: usize,
14231+ using_ptr: *const u8,
14232+ using_len: usize,
14233+) {
14234+ let source = unsafe { str_from_raw(source_ptr, source_len) };
14235+ register_migration_step(
14236+ source,
14237+ MigrationStep {
14238+ step_idx,
14239+ from_schema: unsafe { str_from_raw(from_ptr, from_len) }.to_string(),
14240+ to_schema: unsafe { str_from_raw(to_ptr, to_len) }.to_string(),
14241+ using_src: unsafe { str_from_raw(using_ptr, using_len) }.to_string(),
14242+ },
14243+ );
14244+}
14245+
14246+
14247+/// Read the recorded chain for a source (empty if none registered).
14248+fn migration_chain_for(source: &str) -> Vec<MigrationStep> {
14249+ MIGRATION_CHAINS
14250+ .read()
14251+ .unwrap_or_else(|e| e.into_inner())
14252+ .get(source)
14253+ .cloned()
14254+ .unwrap_or_default()
14255+}
14256+
14257+
1389614258/// Apply a migration to a source relation.
1389714259///
1389814260/// Checks the stored schema in `_knot_schema`:
@@ -23373,6 +23735,231 @@ pub extern "C-unwind" fn knot_api_handle(argc: i32, argv: *const *const u8) -> i
2337323735/// Handle `<program> db` subcommand: launch TUI database explorer.
2337423736/// Returns 1 if handled (caller should exit), 0 otherwise.
2337523737#[unsafe(no_mangle)]
23738+/// Read a NUL-terminated C-string argument (arg index `i`) from argv.
23739+unsafe fn c_arg(argv: *const *const u8, i: i32) -> String {
23740+ unsafe {
23741+ let ptr = *argv.add(i as usize);
23742+ let mut len = 0;
23743+ while *ptr.add(len) != 0 {
23744+ len += 1;
23745+ }
23746+ String::from_utf8_lossy(std::slice::from_raw_parts(ptr, len)).to_string()
23747+ }
23748+}
23749+
23750+/// Minimal ANSI styling for `db info`, applied only on a TTY (plain otherwise,
23751+/// so piped output stays clean).
23752+#[derive(Clone, Copy)]
23753+struct Style {
23754+ tty: bool,
23755+}
23756+
23757+impl Style {
23758+ fn detect() -> Self {
23759+ Style {
23760+ tty: std::io::IsTerminal::is_terminal(&std::io::stdout()),
23761+ }
23762+ }
23763+ fn wrap(&self, code: &str, s: &str) -> String {
23764+ if self.tty {
23765+ format!("\x1b[{}m{}\x1b[0m", code, s)
23766+ } else {
23767+ s.to_string()
23768+ }
23769+ }
23770+ fn bold(&self, s: &str) -> String {
23771+ self.wrap("1", s)
23772+ }
23773+ fn dim(&self, s: &str) -> String {
23774+ self.wrap("2", s)
23775+ }
23776+ fn cyan(&self, s: &str) -> String {
23777+ self.wrap("36", s)
23778+ }
23779+ fn green(&self, s: &str) -> String {
23780+ self.wrap("32", s)
23781+ }
23782+ fn yellow(&self, s: &str) -> String {
23783+ self.wrap("33", s)
23784+ }
23785+}
23786+
23787+/// Human-readable byte size (e.g. `56.0 KB`).
23788+fn human_bytes(n: u64) -> String {
23789+ const KB: u64 = 1024;
23790+ const MB: u64 = KB * 1024;
23791+ const GB: u64 = MB * 1024;
23792+ if n >= GB {
23793+ format!("{:.1} GB", n as f64 / GB as f64)
23794+ } else if n >= MB {
23795+ format!("{:.1} MB", n as f64 / MB as f64)
23796+ } else if n >= KB {
23797+ format!("{:.1} KB", n as f64 / KB as f64)
23798+ } else {
23799+ format!("{} B", n)
23800+ }
23801+}
23802+
23803+/// `<program> db info` — print a human-readable report of the database in knot
23804+/// terms: file summary, then per source its current schema descriptor (the db's
23805+/// actual state), the full baked-in migration chain, and the row count.
23806+fn db_info(db_path: &str) {
23807+ let st = Style::detect();
23808+ let conn = match Connection::open(db_path) {
23809+ Ok(c) => c,
23810+ Err(e) => {
23811+ eprintln!("knot db info: cannot open {}: {}", db_path, e);
23812+ std::process::exit(1);
23813+ }
23814+ };
23815+
23816+ // ── Header: file summary ──
23817+ let size = std::fs::metadata(db_path).map(|m| m.len()).unwrap_or(0);
23818+ let pages: i64 = conn
23819+ .query_row("PRAGMA page_count", [], |r| r.get(0))
23820+ .unwrap_or(0);
23821+ let page_size: i64 = conn
23822+ .query_row("PRAGMA page_size", [], |r| r.get(0))
23823+ .unwrap_or(0);
23824+ let journal: String = conn
23825+ .query_row("PRAGMA journal_mode", [], |r| r.get(0))
23826+ .unwrap_or_default();
23827+ println!("{}", st.bold(db_path));
23828+ println!(
23829+ "{}",
23830+ st.dim(&format!(
23831+ "{} · {} pages × {} B · {}",
23832+ human_bytes(size),
23833+ pages,
23834+ page_size,
23835+ journal
23836+ ))
23837+ );
23838+ println!();
23839+
23840+ // ── Sources ──
23841+ let mut stmt = match conn
23842+ .prepare("SELECT name, schema, applied FROM _knot_schema ORDER BY name")
23843+ {
23844+ Ok(s) => s,
23845+ Err(_) => {
23846+ println!("{}", st.dim("(empty database — no sources)"));
23847+ return;
23848+ }
23849+ };
23850+ let sources: Vec<(String, String, i64)> = stmt
23851+ .query_map([], |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?)))
23852+ .map(|r| r.filter_map(|x| x.ok()).collect())
23853+ .unwrap_or_default();
23854+
23855+ if sources.is_empty() {
23856+ println!("{}", st.dim("(empty database — no sources)"));
23857+ return;
23858+ }
23859+
23860+ println!("{}", st.bold(&format!("{} source{}", sources.len(), if sources.len() == 1 { "" } else { "s" })));
23861+ println!();
23862+
23863+ // Per-relation on-disk footprint: every physical table/index belonging to a
23864+ // source, summed from dbstat. A source `users` owns `_knot_users` (main
23865+ // table), `_knot_users/…` (child/link tables for nested fields), and
23866+ // `_knot_users_…` (its indexes). We match against the *known* source names
23867+ // (longest prefix wins) so a source name containing `_`/`/` isn't split
23868+ // wrongly. Falls back to None when dbstat isn't compiled in.
23869+ let source_names: Vec<&str> = sources.iter().map(|(n, _, _)| n.as_str()).collect();
23870+ let relation_bytes: Option<std::collections::HashMap<String, u64>> = conn
23871+ .prepare("SELECT name, pgsize FROM dbstat")
23872+ .ok()
23873+ .map(|mut s| {
23874+ let mut map: std::collections::HashMap<String, u64> = std::collections::HashMap::new();
23875+ let rows = s.query_map([], |r| Ok((r.get::<_, String>(0)?, r.get::<_, i64>(1)?)));
23876+ if let Ok(rows) = rows {
23877+ for r in rows.flatten() {
23878+ let (tbl, bytes) = (r.0, r.1 as u64);
23879+ let Some(rest) = tbl.strip_prefix("_knot_") else { continue };
23880+ if rest == "schema" {
23881+ continue;
23882+ }
23883+ // Longest known source that is `rest` exactly, or a prefix
23884+ // followed by `_` (index) or `/` (child table).
23885+ let owner = source_names
23886+ .iter()
23887+ .filter(|src| {
23888+ let s: &str = src;
23889+ rest == s
23890+ || rest
23891+ .strip_prefix(s)
23892+ .is_some_and(|t| t.starts_with('_') || t.starts_with('/'))
23893+ })
23894+ .max_by_key(|src| src.len());
23895+ if let Some(owner) = owner {
23896+ *map.entry(owner.to_string()).or_insert(0) += bytes;
23897+ }
23898+ }
23899+ }
23900+ map
23901+ });
23902+
23903+ for (name, schema, applied) in &sources {
23904+ let table = quote_ident(&format!("_knot_{}", name));
23905+ let rows: i64 = conn
23906+ .query_row(&format!("SELECT count(*) FROM {}", table), [], |r| r.get(0))
23907+ .unwrap_or(0);
23908+
23909+ // Source header: *name, size, row count, migration position.
23910+ let size_part = relation_bytes
23911+ .as_ref()
23912+ .and_then(|m| m.get(name.as_str()))
23913+ .map(|b| format!("{} · ", human_bytes(*b)))
23914+ .unwrap_or_default();
23915+ println!(
23916+ "{} {}",
23917+ st.cyan(&format!("*{}", name)),
23918+ st.dim(&format!(
23919+ "{}{} row{} · migration {}",
23920+ size_part,
23921+ rows,
23922+ if rows == 1 { "" } else { "s" },
23923+ applied
23924+ ))
23925+ );
23926+ // Current descriptor (the db's actual state).
23927+ println!("{}", schema);
23928+
23929+ // The full migration chain, each step marked applied / pending.
23930+ let chain = migration_chain_for(name);
23931+ if !chain.is_empty() {
23932+ for (i, step) in chain.iter().enumerate() {
23933+ let last = i + 1 == chain.len();
23934+ let connector = if last { "└─" } else { "├─" };
23935+ if step.step_idx < *applied {
23936+ println!(
23937+ "{} {} {}",
23938+ st.dim(connector),
23939+ st.green("applied"),
23940+ st.dim(&format!("{} → {}", step.from_schema, step.to_schema))
23941+ );
23942+ } else {
23943+ println!(
23944+ "{} {} {}",
23945+ st.dim(connector),
23946+ st.yellow("pending"),
23947+ format!("{} → {}", step.from_schema, step.to_schema)
23948+ );
23949+ }
23950+ }
23951+ }
23952+ println!();
23953+ }
23954+}
23955+
23956+/// `<program> db` subcommands: with no further argument, launch the TUI
23957+/// explorer; `db info` prints a human-readable report (per-source descriptor,
23958+/// migration chain, row counts); `db dump <out>` snapshots the (live) database
23959+/// to a single binary file (`VACUUM INTO`); `db restore <dump>` replaces the
23960+/// database with a dump file (offline). Returns 1 if handled (caller exits), 0
23961+/// to continue.
23962+#[unsafe(no_mangle)]
2337623963pub extern "C-unwind" fn knot_db_handle(
2337723964 argc: i32,
2337823965 argv: *const *const u8,
@@ -23382,21 +23969,64 @@ pub extern "C-unwind" fn knot_db_handle(
2338223969 if argc < 2 {
2338323970 return 0;
2338423971 }
23385− let arg1 = unsafe {
23386− let ptr = *argv.add(1);
23387− let mut len = 0;
23388− while *ptr.add(len) != 0 {
23389− len += 1;
23390− }
23391− String::from_utf8_lossy(std::slice::from_raw_parts(ptr, len)).to_string()
23392− };
23393−
23972+ let arg1 = unsafe { c_arg(argv, 1) };
2339423973 if arg1 != "db" {
2339523974 return 0;
2339623975 }
23397−
2339823976 let db_path = unsafe { str_from_raw(db_path_ptr, db_path_len) };
23977+ let db = std::path::Path::new(db_path);
23978+ let prog = unsafe { c_arg(argv, 0) };
23979+
23980+ // `./app db dump <out>` / `./app db restore <dump>` — binary snapshot.
23981+ if argc >= 3 {
23982+ match unsafe { c_arg(argv, 2) }.as_str() {
23983+ "dump" => {
23984+ if argc < 4 {
23985+ eprintln!("usage: {} db dump <out>", prog);
23986+ std::process::exit(1);
23987+ }
23988+ let out = unsafe { c_arg(argv, 3) };
23989+ match crate::db_dump::dump_db(db, std::path::Path::new(&out)) {
23990+ Ok(()) => {
23991+ println!("Dumped {} -> {}", db.display(), out);
23992+ return 1;
23993+ }
23994+ Err(e) => {
23995+ eprintln!("db dump: {}", e);
23996+ std::process::exit(1);
23997+ }
23998+ }
23999+ }
24000+ "restore" => {
24001+ if argc < 4 {
24002+ eprintln!("usage: {} db restore <dump>", prog);
24003+ std::process::exit(1);
24004+ }
24005+ let dump = unsafe { c_arg(argv, 3) };
24006+ match crate::db_dump::restore_db(std::path::Path::new(&dump), db) {
24007+ Ok(()) => {
24008+ println!("Restored {} -> {}", dump, db.display());
24009+ return 1;
24010+ }
24011+ Err(e) => {
24012+ eprintln!("db restore: {}", e);
24013+ std::process::exit(1);
24014+ }
24015+ }
24016+ }
24017+ "info" => {
24018+ db_info(db_path);
24019+ return 1;
24020+ }
24021+ other => {
24022+ eprintln!("unknown db subcommand: {}", other);
24023+ eprintln!("usage: {} db [info | dump <out> | restore <dump>]", prog);
24024+ std::process::exit(1);
24025+ }
24026+ }
24027+ }
2339924028
24029+ // `./app db` — the TUI explorer.
2340024030 if let Err(e) = tui::run_db_explorer(db_path) {
2340124031 eprintln!("knot db: {}", e);
2340224032 std::process::exit(1);
@@ -23550,68 +24180,40 @@ fn capitalize(s: &str) -> String {
2355024180/// declarations (`name Text`, `age Int`). The descriptor is the knot record
2355124181/// format: fields separated by 2+ spaces, name and type by a single space.
2355224182/// Handles nested relations (`col Rel {inner}`) and ADT tags.
24183+/// Convert a schema descriptor (`{name Text age Int}`) to knot field
24184+/// declarations (`name Text`, `age (Int 1)`). Fields are split with the
24185+/// single-owner `knot-descriptor` grammar (robust to nested `{..}`/`(..)`),
24186+/// then each field's *type* is re-emitted in descriptor form verbatim — except
24187+/// a bare top-level numeric, which gains the dimensionless unit knot source
24188+/// requires (`Int` → `(Int 1)`). Matches the original hand-rolled splitter's
24189+/// output exactly.
2355324190fn schema_descriptor_to_fields(schema: &str) -> Vec<String> {
23554− // Strip ONE outer brace pair.
2355524191 let t = schema.trim();
23556− let inner = if t.starts_with('{') && t.ends_with('}') {
23557− &t[1..t.len() - 1]
24192+ let parsed = if t.starts_with('{') {
24193+ knot_descriptor::parse(t).ok()
2355824194 } else {
23559− t
24195+ // A bare (braceless) field list, e.g. a ctor payload fragment.
24196+ knot_descriptor::parse(&format!("{{{t}}}")).ok()
24197+ };
24198+ let Some(knot_descriptor::Desc::Record(fields)) = parsed else {
24199+ return Vec::new();
2356024200 };
23561− let inner = inner.trim();
23562− // Split on 2+ spaces (the field separator).
23563− let mut fields = Vec::new();
23564− let mut current = String::new();
23565− let mut depth = 0;
23566− let chars: Vec<char> = inner.chars().collect();
23567− let mut i = 0;
23568− while i < chars.len() {
23569− let c = chars[i];
23570− match c {
23571− '{' => { depth += 1; current.push(c); }
23572− '}' => { depth -= 1; current.push(c); }
23573− '[' => { depth += 1; current.push(c); }
23574− ']' => { depth -= 1; current.push(c); }
23575− ' ' if depth == 0 => {
23576− // 2+ spaces separate fields; a single space is within a field.
23577− let mut j = i;
23578− while j < chars.len() && chars[j] == ' ' {
23579− j += 1;
23580− }
23581− if j - i >= 2 {
23582− // A field boundary.
23583− if !current.trim().is_empty() {
23584− fields.push(current.trim().to_string());
23585− }
23586− current = String::new();
23587− i = j;
23588− continue;
23589− }
23590− current.push(c);
23591− }
23592− _ => current.push(c),
23593− }
23594− i += 1;
23595− }
23596− if !current.trim().is_empty() {
23597− fields.push(current.trim().to_string());
23598− }
23599− // The descriptor stores `Int`/`Float` without a unit. Knot requires one —
23600− // emit `Int 1`/`Float 1` (dimensionless) for a bare numeric type.
2360124201 fields
2360224202 .into_iter()
23603− .map(|f| {
23604− let parts: Vec<&str> = f.splitn(2, ' ').collect();
23605− if parts.len() == 2 {
23606− let (name, ty) = (parts[0], parts[1]);
23607− match ty {
23608− "Int" => format!("{} (Int 1)", name),
23609− "Float" => format!("{} (Float 1)", name),
23610− _ => f,
24203+ .map(|(name, ty)| {
24204+ let ty_str = match ty {
24205+ // Bare numerics need a dimensionless unit in knot source.
24206+ knot_descriptor::Desc::Scalar(knot_descriptor::Scalar::Int) => {
24207+ "(Int 1)".to_string()
2361124208 }
23612− } else {
23613− f
23614− }
24209+ knot_descriptor::Desc::Scalar(knot_descriptor::Scalar::Float) => {
24210+ "(Float 1)".to_string()
24211+ }
24212+ // Everything else round-trips to the descriptor spelling
24213+ // (`(Rel T)`, `(A | B)`, nested records) verbatim.
24214+ other => knot_descriptor::emit(&other),
24215+ };
24216+ format!("{} {}", name, ty_str)
2361524217 })
2361624218 .collect()
2361724219}
@@ -23631,51 +24233,39 @@ fn qualify_adt_ctor(value: &str, type_name: &str) -> String {
2363124233}
2363224234
2363324235/// Convert an ADT schema descriptor (`(Circle{radius Int} | Point)`) to knot
23634−/// constructor declarations (`Circle {radius (Int 1)}`, `Point {}`).
24236+/// constructor declarations (`Circle {radius (Int 1)}`, `Point {}`), using the
24237+/// single-owner `knot-descriptor` grammar.
2363524238fn schema_descriptor_to_adt_ctors(schema: &str) -> Vec<String> {
23636− // Strip the outer parens and split on ` | `.
23637− let inner = schema.trim().trim_start_matches('(').trim_end_matches(')').trim();
23638− let mut ctors = Vec::new();
23639− let mut current = String::new();
23640− let mut depth = 0;
23641− let chars: Vec<char> = inner.chars().collect();
23642− let mut i = 0;
23643− while i < chars.len() {
23644− let c = chars[i];
23645− match c {
23646− '{' => { depth += 1; current.push(c); }
23647− '}' => { depth -= 1; current.push(c); }
23648− '|' if depth == 0 => {
23649− if !current.trim().is_empty() {
23650− ctors.push(adt_ctor_to_knot(current.trim()));
23651− }
23652− current = String::new();
23653− i += 1;
23654− continue;
23655− }
23656− _ => current.push(c),
23657− }
23658− i += 1;
23659− }
23660− if !current.trim().is_empty() {
23661− ctors.push(adt_ctor_to_knot(current.trim()));
23662− }
23663− ctors
24239+ let Ok(knot_descriptor::Desc::Variant(ctors)) = knot_descriptor::parse(schema.trim())
24240+ else {
24241+ return Vec::new();
24242+ };
24243+ ctors.iter().map(|(name, fields)| adt_ctor_to_knot(name, fields)).collect()
2366424244}
2366524245
23666−/// Convert one ADT constructor descriptor (`Circle{radius Int}` or `Point`) to
23667−/// the knot form (`Circle {radius (Int 1)}` or `Point {}`).
23668−fn adt_ctor_to_knot(ctor: &str) -> String {
23669− let ctor = ctor.trim();
23670− if let Some(brace) = ctor.find('{') {
23671− let name = ctor[..brace].trim();
23672− let fields_str = &ctor[brace..]; // `{radius Int}`
23673− let fields = schema_descriptor_to_fields(fields_str);
23674− format!("{} {{{}}}", name, fields.join(" "))
23675− } else {
24246+/// Convert one ADT constructor (name + payload fields) to the knot form
24247+/// (`Circle {radius (Int 1)}` or `Point {}`).
24248+fn adt_ctor_to_knot(name: &str, fields: &[(String, knot_descriptor::Desc)]) -> String {
24249+ if fields.is_empty() {
2367624250 // A nullary constructor.
23677− format!("{} {{}}", ctor)
24251+ return format!("{} {{}}", name);
2367824252 }
24253+ let specs: Vec<String> = fields
24254+ .iter()
24255+ .map(|(n, t)| {
24256+ let ty_str = match t {
24257+ knot_descriptor::Desc::Scalar(knot_descriptor::Scalar::Int) => {
24258+ "(Int 1)".to_string()
24259+ }
24260+ knot_descriptor::Desc::Scalar(knot_descriptor::Scalar::Float) => {
24261+ "(Float 1)".to_string()
24262+ }
24263+ other => knot_descriptor::emit(other),
24264+ };
24265+ format!("{} {}", n, ty_str)
24266+ })
24267+ .collect();
24268+ format!("{} {{{}}}", name, specs.join(" "))
2367924269}
2368024270
2368124271fn generate_openapi(name: &str, table: &RouteTable) -> String {
@@ -24986,3 +25576,126 @@ mod show_tests {
2498625576 );
2498725577 }
2498825578}
25579+
25580+#[cfg(test)]
25581+mod compile_containment_tests {
25582+ //! `base.compile` runs in the host's process; a panic in compilation or the
25583+ //! generated snippet must surface as an Err, never unwind across `extern
25584+ //! "C"`. These exercise the shared containment point both `knot_compile_impl`
25585+ //! entry points delegate to.
25586+ use super::*;
25587+
25588+ /// Out-param slots for a contained run, mirroring the FFI caller.
25589+ struct Outs {
25590+ ty: *mut u8,
25591+ ty_len: usize,
25592+ rels: *mut u8,
25593+ rels_len: usize,
25594+ err: *mut u8,
25595+ err_len: usize,
25596+ }
25597+ impl Outs {
25598+ fn new() -> Self {
25599+ Outs {
25600+ ty: std::ptr::null_mut(),
25601+ ty_len: 0,
25602+ rels: std::ptr::null_mut(),
25603+ rels_len: 0,
25604+ err: std::ptr::null_mut(),
25605+ err_len: 0,
25606+ }
25607+ }
25608+ unsafe fn init(&mut self) {
25609+ unsafe {
25610+ compile_out_params_init([
25611+ (&mut self.ty, &mut self.ty_len),
25612+ (&mut self.rels, &mut self.rels_len),
25613+ (&mut self.err, &mut self.err_len),
25614+ ]);
25615+ }
25616+ }
25617+ fn err_string(&self) -> Option<String> {
25618+ if self.err.is_null() {
25619+ return None;
25620+ }
25621+ let bytes = unsafe { std::slice::from_raw_parts(self.err, self.err_len) };
25622+ let s = String::from_utf8_lossy(bytes).into_owned();
25623+ unsafe { libc::free(self.err as *mut c_void) };
25624+ Some(s)
25625+ }
25626+ }
25627+
25628+ #[test]
25629+ fn success_writes_out_params_and_returns_value() {
25630+ let mut o = Outs::new();
25631+ unsafe { o.init() };
25632+ let sentinel = 0xdeadbeefusize as *mut Value;
25633+ let v = unsafe {
25634+ run_compile_contained(
25635+ std::ptr::null_mut(),
25636+ &mut o.ty,
25637+ &mut o.ty_len,
25638+ &mut o.rels,
25639+ &mut o.rels_len,
25640+ &mut o.err,
25641+ &mut o.err_len,
25642+ |_| Some(CompileOutcome {
25643+ value: sentinel,
25644+ ty: Some("Int".to_string()),
25645+ relations: vec!["a\t{..}".to_string()],
25646+ }),
25647+ )
25648+ };
25649+ assert_eq!(v, sentinel);
25650+ assert!(!o.ty.is_null(), "ty written");
25651+ assert!(!o.rels.is_null(), "relations written");
25652+ assert!(o.err.is_null(), "no error on success");
25653+ unsafe {
25654+ libc::free(o.ty as *mut c_void);
25655+ libc::free(o.rels as *mut c_void);
25656+ }
25657+ }
25658+
25659+ #[test]
25660+ fn clean_compile_error_returns_null() {
25661+ let mut o = Outs::new();
25662+ unsafe { o.init() };
25663+ let v = unsafe {
25664+ run_compile_contained(
25665+ std::ptr::null_mut(),
25666+ &mut o.ty,
25667+ &mut o.ty_len,
25668+ &mut o.rels,
25669+ &mut o.rels_len,
25670+ &mut o.err,
25671+ &mut o.err_len,
25672+ |_| None,
25673+ )
25674+ };
25675+ assert!(v.is_null(), "compile error returns null");
25676+ }
25677+
25678+ #[test]
25679+ fn panic_is_contained_and_surfaced_as_err() {
25680+ let mut o = Outs::new();
25681+ unsafe { o.init() };
25682+ let v = unsafe {
25683+ run_compile_contained(
25684+ std::ptr::null_mut(),
25685+ &mut o.ty,
25686+ &mut o.ty_len,
25687+ &mut o.rels,
25688+ &mut o.rels_len,
25689+ &mut o.err,
25690+ &mut o.err_len,
25691+ |_| -> Option<CompileOutcome> { panic!("boom in codegen") },
25692+ )
25693+ };
25694+ assert!(v.is_null(), "panic returns null, does not unwind");
25695+ let err = o.err_string().expect("panic message written to out_err");
25696+ assert!(
25697+ err.contains("compile panicked") && err.contains("boom in codegen"),
25698+ "panic payload surfaced: {err}"
25699+ );
25700+ }
25701+}