# Core module builder function # This is the main entry point for building Logos modules. # Plugin compilation and header generation are delegated to a backend selected # by metadata.json "type": core modules use coreBackend, UI modules use uiBackend. { nixpkgs, lib, common, parseMetadata, builderRoot, uiBackend, coreBackend, logos-cpp-sdk, logos-protocol ? null, logos-qt-sdk ? null, logos-plugin-qt ? null, logos-view-module, logos-module, logos-test-framework, logos-rust-sdk ? null, nix-bundle-lgx, nix-bundle-logos-module-install, logos-standalone-app, rust-overlay ? null }: { # Required: Path to the module source src, # Required: Path to the metadata.json configuration file configFile, # Optional: all flake inputs — dependencies in metadata.json are resolved automatically flakeInputs ? {}, # Optional: Additional flake inputs for external libraries externalLibInputs ? {}, # Optional: Extra build inputs to add extraBuildInputs ? [], # Optional: Extra native build inputs to add extraNativeBuildInputs ? [], # Optional: Extra inputs/env for the Rust crate compile (cdylib modules). # Programmatic escape hatch complementing metadata `nix.rust` — for arbitrary # derivations or store-path env that can't be named by a nixpkgs attr path. # Merged on top of the metadata-declared inputs (rustEnv wins on key conflict). rustExtraNativeBuildInputs ? [], rustExtraBuildInputs ? [], rustEnv ? {}, # Optional: Override any config values configOverrides ? {}, # Optional: Custom preConfigure hook preConfigure ? "", # Optional: Custom postInstall hook postInstall ? "", # Optional: override the logos-standalone-app used for `nix run`. # By default, UI modules (type = "ui") automatically get apps.default wired up # using the standalone app bundled with logos-module-builder. logosStandalone ? null, # Optional: Unit test configuration. When provided, a checks..unit-tests # output is automatically generated using logos-test-framework. # tests = { # dir = ./tests; # Required: directory containing test sources + CMakeLists.txt # mockCLibs = []; # Optional: C libraries to mock at link time # preConfigure = ""; # Optional: custom preConfigure hook # extraBuildInputs = []; # extraCmakeFlags = []; # }; tests ? null, }: let # Parse the module configuration rawConfig = parseMetadata.parseModuleConfig (builtins.readFile configFile); config = common.recursiveMerge [ rawConfig configOverrides ]; # Select backend based on module type: core modules are swappable, UI stays Qt selectedBackend = if config.type == "core" then coreBackend else uiBackend; # Import sub-builders (backend-agnostic) mkExternalLib = import ./mkExternalLib.nix { inherit lib common; }; mkStandaloneApp = import ./mkStandaloneApp.nix; modulePreConfigure = import ./modulePreConfigure.nix { inherit lib; }; # cmake/LogosModule.cmake lives HERE and nowhere else — logos-plugin-qt used # to ship a second copy, and this was a `pathExists` probe whose miss handed # the build to that copy instead. There is nothing to fall back to now, so a # miss throws rather than silently configuring against another file. builderCmakeRoot = if builtins.pathExists (builderRoot + "/cmake/LogosModule.cmake") then "${builderRoot}" else throw ("logos-module-builder: cmake/LogosModule.cmake is missing from " + "${toString builderRoot}. It is the only copy; no backend ships one."); # Helper to get a package from nixpkgs by name getPkg = pkgs: name: let evaluatedName = builtins.seq name name; in if builtins.isString evaluatedName then lib.getAttrFromPath (lib.splitString "." evaluatedName) pkgs else builtins.throw "getPkg expected string but got ${builtins.typeOf evaluatedName}"; forAllSystems = f: lib.genAttrs common.systems (system: f system); # Package outputs packages = forAllSystems (system: let pkgs = common.mkPkgs system; # Rust target triple when `system` is a cross pseudo-system; null natively. # Every cross branch below keys off this being non-null, so a native build # takes exactly the code path it did before. rustCrossTarget = if system == "x86_64-windows" then "x86_64-pc-windows-gnu" else null; # Rust toolchain for the crate compile. Default = the pinned nixpkgs rustc, # so non-Rust modules and Rust modules without a `nix.rust.toolchain` are # unchanged. When a module sets `nix.rust.toolchain` (e.g. "1.96.0") and the # builder has a rust-overlay input, use a rust-overlay stable toolchain at # that version — for crates whose deps need a newer rustc than nixpkgs ships # (the railgun engine's alloy 1.8 / ruint need >= 1.91). rustPlatform = if config.nix_rust.toolchain != null && rust-overlay != null then let # The toolchain must RUN on the builder and merely TARGET `system`. # Asking the CROSS set for rust-bin evaluates # `targetPackages.threads.package` (nixpkgs all-packages.nix) -- # an attribute only the MinGW branch touches and that the cross set # does not define -- and mkPkgsWith refuses overlays for # x86_64-windows for the same "that is not the set you asked for" # reason. Taking it from the BUILD system sidesteps both, and is # what a cross toolchain should be regardless. # buildSystemFor is the identity on every native system, so this is # a no-op there. bpkgs = common.mkPkgsWith [ (import rust-overlay) ] (common.buildSystemFor system); base = bpkgs.rust-bin.stable.${config.nix_rust.toolchain}.default; toolchain = if rustCrossTarget == null then base else base.override { targets = [ rustCrossTarget ]; }; in bpkgs.makeRustPlatform { cargo = toolchain; rustc = toolchain; } else pkgs.rustPlatform; # Cross wiring for the crate compile. The derivation runs in the BUILD # platform's stdenv (see rustPlatform above), so nothing sets these for us. rustCrossEnv = if rustCrossTarget == null then { } else let cc = pkgs.stdenv.cc; # `pkgs` is the TARGET set: the mingw wrapper u = builtins.replaceStrings [ "-" ] [ "_" ] rustCrossTarget; U = lib.toUpper u; in { CARGO_BUILD_TARGET = rustCrossTarget; "CARGO_TARGET_${U}_LINKER" = "${cc}/bin/${cc.targetPrefix}cc"; # windows-gnu std links `-l:libpthread.a`, but nixpkgs builds # mingw-w64 against mcfgthread, which ships no pthreads at all. "CARGO_TARGET_${U}_RUSTFLAGS" = "-L native=${pkgs.windows.pthreads}/lib"; # cc-rs keys its toolchain off CC_/CXX_/AR_ with dashes # replaced by underscores. Without these a build script compiles its # bundled C for the BUILDER and the link then fails on undefined # symbols -- silently, because the archive is still produced. "CC_${u}" = "${cc}/bin/${cc.targetPrefix}cc"; "CXX_${u}" = "${cc}/bin/${cc.targetPrefix}c++"; "AR_${u}" = "${cc.bintools}/bin/${cc.targetPrefix}ar"; # The header half of the same pthreads story as RUSTFLAGS above. # mingw-w64 DOES ship , and -- # but in the winpthreads package, which is not on the default # sysroot include path because nixpkgs builds mingw against # mcfgthread. A crate's vendored C that reaches for them therefore # fails with a bare "fatal error: sched.h: No such file or # directory" that reads like the platform is unsupported when it is # only unwired. aws-lc-sys hits exactly this, compiling # jitterentropy for the Windows target. # # cc-rs appends CFLAGS_/CXXFLAGS_ to the compiler # invocations it drives, so this reaches build-script C without # touching the Rust compile. "CFLAGS_${u}" = "-I${pkgs.windows.pthreads}/include"; "CXXFLAGS_${u}" = "-I${pkgs.windows.pthreads}/include"; }; # ── Concrete dependency classification ───────────────────────────────── # A dependency's typed `modules().` wrapper is generated from its # published LIDL contract (`packages..lidl`) WITHOUT building the # dep's plugin. Deps that don't expose a `lidl` output yet take the # TRANSITIONAL header-copy fallback (`legacyHeaderDepNames`), which DOES # build them — identical to today's behavior. # Returns the dep's published LIDL output, or null if the input isn't a # flake exposing packages..lidl (e.g. a raw-derivation dep, or a # module built by a builder that predates this feature) — those fall # through to the TRANSITIONAL header-copy path. Guard every level so a # non-flake input never throws. depLidlOf = name: let i = flakeInputs.${name} or null; in if i != null && i ? packages && i.packages ? ${system} then (i.packages.${system}.lidl or null) else null; depIsLidl = name: (config.dependency_overrides ? ${name}) || (depLidlOf name != null); # LIDL-based deps → `--dep =` for the generator. An override # forces a specific definition (.lidl, or .h + impl_class); otherwise we # use the dep's published `lidl` output. staticDeps = map (name: let ov = config.dependency_overrides.${name} or null; in if ov != null then { inherit name; impl_class = ov.impl_class; path = if ov.input != null then (if flakeInputs ? ${ov.input} then "${flakeInputs.${ov.input}}/${ov.file}" else throw "dependency_overrides.${name}: flake input '${ov.input}' was not passed to mkLogosModule.") else "${src}/${ov.file}"; } else { inherit name; impl_class = null; path = "${depLidlOf name}/${name}.lidl"; } ) (lib.filter depIsLidl config.dependencies); # TRANSITIONAL: header-copy fallback for deps that predate the `lidl` # output. These deps ARE built (their headers come from introspecting the # compiled plugin). Remove this block — and the `moduleDepIncludes` use in # the plugin backends — once every module exposes packages..lidl. legacyHeaderDepNames = lib.filter (name: !(depIsLidl name)) config.dependencies; # Resolve the fallback deps from inputs. Each entry is exposed # as a struct so the plugin builder can pick BOTH the dep's # plugin .dylib AND the right header variant for its own # --api-style without re-running the codegen at consume time. # Shared with buildCppPlugin (view modules) — see common.nix. resolvedModuleDeps = common.resolveLegacyHeaderDeps { inherit system flakeInputs; depNames = legacyHeaderDepNames; }; # Resolve interface dependencies (method/event contracts) to concrete # definition-file paths. A LOCAL interface lives in this repo's `src`; # a REMOTE one comes from a flake input named by `input` — mirroring # how `dependencies` resolve to flake inputs. We resolve the path here # so the generator never touches flake inputs: it just receives # `--interface =[=]`. (System-independent, but # kept in this scope alongside resolvedModuleDeps for locality.) resolvedInterfaceDeps = map (e: { inherit (e) name impl_class; path = if e.input != null then (if flakeInputs ? ${e.input} then "${flakeInputs.${e.input}}/${e.file}" else throw "interface_dependencies: interface '${e.name}' references flake input '${e.input}', but no such input was passed to mkLogosModule (declare it in flake.nix and pass it via flakeInputs).") else "${src}/${e.file}"; }) config.interface_dependencies; # Resolve a single externalLibInputs entry for a given variant. # Supports both simple (bare flake input) and structured ({ input, packages }) formats. resolveExtInput = variant: name: value: if builtins.isAttrs value && value ? input then let flakeInput = value.input; packages = value.packages or {}; pkgName = packages.${variant} or packages.default or "default"; in if flakeInput ? packages.${system}.${pkgName} then flakeInput.packages.${system}.${pkgName} else builtins.throw '' External lib "${name}": flake input does not provide packages.${system}.${pkgName}. Check the "externalLibInputs" structured entry and ensure the flake input exposes the expected package. '' else if value ? packages.${system}.default then value.packages.${system}.default else value; # Whether any external lib input declares per-variant packages hasVariants = lib.any (v: builtins.isAttrs v && v ? input && v ? packages) (lib.attrValues externalLibInputs); buildPkgs = map (getPkg pkgs) (lib.filter builtins.isString config.nix_packages.build); runtimePkgs = map (getPkg pkgs) (lib.filter builtins.isString config.nix_packages.runtime); # Rust crate compile inputs (metadata nix.rust). build -> nativeBuildInputs # (host tools), runtime -> buildInputs (link libs). Resolved with the same # dotted-path getPkg as buildPkgs/runtimePkgs. Fed only to rustStaticLib, # not the C++ plugin link. # buildPackages, not pkgs: these are TOOLS that run on the builder # (pkg-config, perl, protobuf, cmake). Under cross, resolving them from # the target set would try to build each one FOR Windows. Identity on # every native system, so no native derivation changes. rustNativeBuildPkgs = map (getPkg pkgs.buildPackages) (lib.filter builtins.isString config.nix_rust.packages.build); rustBuildPkgs = map (getPkg pkgs) (lib.filter builtins.isString config.nix_rust.packages.runtime); # Pre-resolve default variant external libs (always needed, avoids # duplicate evaluation when hasVariants triggers a second buildVariant). defaultResolvedExternalLibs = lib.mapAttrs (resolveExtInput "default") externalLibInputs; defaultExternalLibs = mkExternalLib.buildExternalLibs { inherit pkgs config src; externalInputs = defaultResolvedExternalLibs; }; # metadata `include`: runtime files a module needs BESIDE its plugin but # never links against -- in practice, dlopen'd libraries. # # Nothing else can stage these. The Windows DLL walk # (logos-plugin-qt postFixup -> linkDLLsInfolder) is IMPORT-TABLE driven, # so a library reached only through dlopen appears in no table and is # invisible to it; on Unix there is equally no DT_NEEDED entry to follow. # delivery_module hit exactly this with libpq: declared, needed at # runtime, and silently absent from the module output. # # Sources are the module's own runtime nix packages and its resolved # external libs; both `lib/` and `bin/` are searched, because a Windows # shared library's runtime half lives in bin/ by convention. # # A name that matches nothing is NORMAL, not an error: the list is a # deliberate cross-platform superset (modules name the .so, .dylib and # .dll spellings side by side), so at most one spelling can ever match. # # Runs BEFORE the module's own postInstall, so author hooks can react to # what was staged, and before the Windows postFixup, so linkDLLsInfolder # then also walks the staged library's OWN imports (libpq pulls in # libssl/libcrypto that way). stageIncludedRuntimeFiles = let sources = runtimePkgs ++ lib.attrValues defaultResolvedExternalLibs; in lib.optionalString (config.include != [ ] && sources != [ ]) '' echo "Staging declared runtime files (metadata 'include')..." mkdir -p $out/lib for _inc_name in ${lib.escapeShellArgs config.include}; do for _inc_root in ${lib.escapeShellArgs (map toString sources)}; do for _inc_sub in lib bin; do if [ -e "$_inc_root/$_inc_sub/$_inc_name" ]; then cp -Lf "$_inc_root/$_inc_sub/$_inc_name" "$out/lib/" 2>/dev/null \ && echo " staged $_inc_name" && break 2 fi done done done ''; # Resolve SDK deps for this system — injected into the backend logosSdk = logos-cpp-sdk.packages.${system}.default; # Build-platform half of the SDK. logos-cpp-generator is invoked by BARE # NAME from a build phase (logos-plugin-qt/lib/buildPlugin.nix:145), so it # must run on the builder. Under cross, packages.x86_64-windows.default # carries no runnable generator at all -- logos-cpp-sdk/nix/bin.nix:39 # silently skips the mingw .exe -- hence "command not found". # # `logosSdk` deliberately stays TARGET-typed: it is ALSO the header and # CMake-package root passed to LOGOS_CPP_SDK_ROOT, and those must keep # coming from the Windows set. Splitting the two roles is the whole point; # pointing the headers at the build system would produce a build that # SUCCEEDS while linking the wrong architecture. # # buildSystemFor is the identity on every native system, so this is a # no-op off the Windows target. logosSdkBuild = logos-cpp-sdk.packages.${common.buildSystemFor system}.default; logosQtSdk = logos-qt-sdk.packages.${system}.default; # The Qt HOST RUNTIME (LogosAPI, LogosAPIProvider, LogosProviderBase, the # legacy PluginInterface) a plugin links. It moved out of logos-qt-sdk # into logos-plugin-qt and ships as `logos-qt-host`; logos-qt-sdk still # forwards it, so this is the repoint, not a new dependency. TARGET-typed # like logosQtSdk — it is a library that gets linked into the plugin. # logos-qt-sdk stays for what the host runtime never carried: the # Qt-typed logos_qt_lp_bridge.h / logos_qt_wire.h / logos_ui_plugin_context.h # and the logos-qt-generator that emits #includes of them. logosQtHost = logos-plugin-qt.packages.${system}.logos-qt-host; # The Qt glue generator (universal/cdylib/ui backends) — Qt code is # the Qt layer's product; logos-cpp-generator keeps Qt-free outputs. logosQtGenerator = logos-qt-sdk.packages.${common.buildSystemFor system}.logos-qt-generator; # The cdylib Qt-plugin glue generator lives in logos-plugin-qt (the Qt # plugin BACKEND owns the glue; the SDK does not). logos-qt-sdk still # ships an older copy of the SAME emitter, and calling that one is not a # compile error — it silently emits STALE glue. That is how a # host-services grant went undelivered while every build stayed green. logosQtHostGenerator = logos-plugin-qt.packages.${common.buildSystemFor system}.logos-qt-host-generator; # The four LogosView*.in templates logos_module(REP_FILE ...) instantiates. # They live in logos-view-module (the ui_qml authoring flavour), NOT in # the plugin backend any more, and cmake/LogosModule.cmake here refuses to # guess — it hard-errors unless handed LOGOS_VIEW_TEMPLATE_DIR. # # buildSystemFor, not plain ${system}: these are text files with no # platform dimension, and logos-view-module publishes only the four # NATIVE systems, so `packages.x86_64-windows` would EVAL-fail on the # Windows leg — a failure that is invisible until someone crosses. viewTemplates = logos-view-module.packages.${common.buildSystemFor system}.logos-view-templates; logosProtocolPkg = logos-protocol.packages.${system}.default; logosModule = logos-module.packages.${system}.default; # The logos-protocol semver — parsed from the protocol header the # whole stack links. Stamped into every module's embedded metadata # (see modulePreConfigure.stampProtocolVersion). null (no stamp) only # if the input is somehow absent — modules then load as "legacy". protocolVersion = if logos-protocol == null then null else let header = builtins.readFile "${logos-protocol}/cpp/logos_protocol.h"; parts = builtins.split "LOGOS_PROTOCOL_VERSION_STRING \"([^\"]*)\"" header; in if builtins.length parts < 2 then null else builtins.head (builtins.elemAt parts 1); # ── Rust cdylib authoring (codegen.rust) ─────────────────────────────── # A Rust module's module-impl C ABI scaffold (logos_module_* exports + # typed trait + RustModuleContext + dep clients) is generated from the SAME # .lidl contract that drives the Qt glue, and the crate is compiled to a # staticlib — both done HERE by the builder, exactly as it runs the C++ # generator. The author writes no build.rs and the module's flake stays # trivial (no buildRustPackage / preConfigure staging). # # logos-lidl-gen AND the SDK source the crate links both come from this # builder's own logos-rust-sdk input — so a Rust module's flake.nix is # identical to a C++ one (just logos-module-builder), and the generator and # the runtime SDK are the SAME pinned rev (no skew). logos-rust-sdk depends # back on this builder for its tests, so its module-builder input is cut # with `follows` in flake.nix to break the cycle (see there). isRustModule = (config.codegen or {}) ? rust; rustCfg = (config.codegen or {}).rust or {}; rustCrateDir = "${src}/${rustCfg.crate or (throw "codegen.rust must set 'crate' (the crate directory, e.g. \"rust-lib\") in ${config.name}")}"; # The staticlib basename (produces lib.a) — read from the crate's # Cargo.toml ([lib].name, else [package].name with - -> _) so the author # needn't repeat it. codegen.rust.staticlib still overrides if set. rustCargoToml = if !isRustModule then {} else builtins.fromTOML (builtins.readFile "${rustCrateDir}/Cargo.toml"); rustStaticName = rustCfg.staticlib or (rustCargoToml.lib.name or (lib.replaceStrings ["-"] ["_"] rustCargoToml.package.name)); # Rust-FIRST authoring: when codegen.rust names the contract `trait`, that # trait is declared in the crate and the .lidl is DERIVED from it at build # time (logos-lidl-gen --from-rust over the crate source) — exactly as a # universal C++ module derives its .lidl from the impl header. The .rs file # is the single source of truth: no committed .lidl, no manual derive step. # The scaffold is then generated with --no-trait (the trait is the # author's). Without `trait`, the module is contract-first: codegen.lidl is # a committed file and the trait is generated. rustTrait = rustCfg.trait or null; rustDeriveMode = rustTrait != null; # The .rs file holding the trait (+ optional Events companion), # relative to the crate dir. rustSource = rustCfg.source or "src/lib.rs"; rustSdk = if !isRustModule then null else if logos-rust-sdk == null then throw "codegen.rust module '${config.name}' requires logos-module-builder to be built with a logos-rust-sdk input (it provides the lidl-gen generator + the SDK source). Update the builder." else logos-rust-sdk; # lidl-gen is a build-time TOOL: it runs on the builder to emit the Rust # scaffold. Resolving it from the TARGET set asks logos-rust-sdk for an # x86_64-windows attribute it does not publish -- and which would be an # unrunnable PE if it did. buildSystemFor is the identity natively. rustGen = if !isRustModule then null else rustSdk.packages.${common.buildSystemFor system}.lidl-gen; # The dep contracts that feed the Rust generator: the same resolved # concrete + interface deps the C++ generator gets. Concrete deps → # `modules().`; interface deps → a bound client (`Client::bind`). # Both arrive as `--dep name=` (the Rust CLI has no separate # interface flag — every generated client carries new() AND bind()). rustDepFlags = lib.concatStringsSep " " ( (map (d: "--dep ${d.name}=${d.path}") staticDeps) ++ (map (e: "--dep ${e.name}=${e.path}") resolvedInterfaceDeps) ); # The contract .lidl, derived from the crate's trait in rust-first mode. # Reused by the scaffold gen, the Qt glue (staged into the build below), and # the published `packages..lidl`. derivedLidl = if !rustDeriveMode then null else pkgs.runCommand "logos-${config.name}-derived-lidl" { nativeBuildInputs = [ rustGen ]; } '' mkdir -p $out logos-lidl-gen --from-rust "${rustCrateDir}/${rustSource}" \ --trait ${rustTrait} --module-name ${config.name} --module-version ${config.version} \ -o "$out/${config.name}.lidl" ''; # The .lidl the generators consume: the derived one (rust-first) or the # committed codegen.lidl (contract-first). rustLidlPath = if rustDeriveMode then "${derivedLidl}/${config.name}.lidl" else "${src}/${config.codegen.lidl}"; # A Rust module's EXPORT SET is decided by this string: lidl-gen gates # logos_module_grant_host_services on >= 0.3 and the teardown pair on # >= 0.5. So it cannot be optional here the way it is for metadata # stamping below, where null legitimately means "pre-protocol, load as # legacy". # # It used to be passed as # ${lib.optionalString (protocolVersion != null) "--protocol-version ..."} # which does not make a bad version WRONG — it makes the flag VANISH. # lidl-gen then falls back to "0.1.0", emits the seven founding exports, # and exits 0. Every Rust module in the workspace would quietly regenerate # incomplete, link cleanly, and fail at dlopen() on Linux with an # undefined symbol — invisible on macOS, and three repos away from here. # # checks.module-impl-abi-nm DETECTS that by reading the built plugin's # symbol table. This is the other half: refuse at the point of the # mistake, so it never reaches a build. The two causes need different # messages because they are different bugs. rustProtocolVersion = if protocolVersion != null then protocolVersion else if logos-protocol == null then throw ("logos-module-builder: module '" + config.name + "' is a Rust " + "cdylib (codegen.rust), but this builder has no logos-protocol " + "input, so the module-impl C ABI version it must generate against " + "is unknown. Generating anyway would emit the pre-0.3 export set " + "and produce a module that fails to dlopen.") else throw ("logos-module-builder: could not read " + "LOGOS_PROTOCOL_VERSION_STRING from ${logos-protocol}/cpp/" + "logos_protocol.h, needed to generate the Rust cdylib scaffold " + "for '" + config.name + "'. The header moved or changed shape — " + "fix the parse above; do NOT let it fall back, because the " + "fallback silently emits an incomplete module-impl C ABI."); rustScaffold = if !isRustModule then null else pkgs.runCommand "logos-${config.name}-rust-scaffold" { nativeBuildInputs = [ rustGen ]; } '' mkdir -p $out logos-lidl-gen "${rustLidlPath}" --provider ${lib.optionalString rustDeriveMode "--no-trait"} \ ${lib.optionalString ((config.concurrency or "single") == "multi") "--concurrency multi"} ${rustDepFlags} \ --protocol-version ${rustProtocolVersion} \ -o "$out/provider_gen.rs" ''; # Rust-first only: stage the derived .lidl into generated_code/ BEFORE the # Qt-glue codegen runs — that's where cdylibCodegen reads it for a rust-first # module (so no codegen.lidl is needed in metadata; the builder owns the # path). Empty for contract-first, where the .lidl is committed. lidlStaging = lib.optionalString rustDeriveMode '' mkdir -p generated_code cp ${derivedLidl}/${config.name}.lidl generated_code/${config.name}.lidl ''; # The crate source laid out for the build: the crate under rust-lib/ (with # the generated scaffold injected at generated/provider_gen.rs) and the # builder's logos-rust-sdk source alongside it, so the crate's # `logos-rust-sdk = { path = "../logos-rust-sdk-src" }` dep resolves against # the SAME rev the generator came from. The author crate carries only the # trait impl + hook — no build.rs, no OUT_DIR. rustCrateSrc = if !isRustModule then null else pkgs.runCommand "logos-${config.name}-rust-src" {} '' mkdir -p $out cp -r ${rustCrateDir} $out/rust-lib chmod -R u+w $out/rust-lib mkdir -p $out/rust-lib/generated cp ${rustScaffold}/provider_gen.rs $out/rust-lib/generated/provider_gen.rs cp -r ${rustSdk} $out/logos-rust-sdk-src ''; rustStaticLib = if !isRustModule then null else rustPlatform.buildRustPackage ({ pname = rustStaticName; version = config.version; src = rustCrateSrc; sourceRoot = "logos-${config.name}-rust-src/rust-lib"; cargoLock = { lockFile = "${rustCrateDir}/Cargo.lock"; allowBuiltinFetchGit = true; }; # External system build deps for the crate compile — from metadata # `nix.rust` plus the programmatic escape-hatch args. Empty by default, # so modules with no native deps build exactly as before. nativeBuildInputs = rustNativeBuildPkgs ++ rustExtraNativeBuildInputs # The cc-rs / linker wiring above names the cross compiler by store # path, but build scripts also expect it on PATH. ++ lib.optional (rustCrossTarget != null) pkgs.stdenv.cc; buildInputs = rustBuildPkgs ++ rustExtraBuildInputs; env = config.nix_rust.env // rustEnv // rustCrossEnv; doCheck = false; } # nixpkgs' cargoBuildHook derives `--target` from the stdenv's HOST # platform, and this derivation deliberately runs in the BUILD # platform's stdenv (see rustPlatform above) so that the toolchain is # runnable. Left alone it therefore builds for the BUILDER -- silently, # producing a perfectly good Linux archive that then fails to link into # a PE. Drive cargo directly for the cross case instead. // lib.optionalAttrs (rustCrossTarget != null) { buildPhase = '' runHook preBuild export CARGO_HOME=$TMPDIR/cargo cargo build --release --offline --target ${rustCrossTarget} runHook postBuild ''; installPhase = '' runHook preInstall mkdir -p $out/lib cp target/${rustCrossTarget}/release/lib${rustStaticName}.a $out/lib/ runHook postInstall ''; }); # Stage the compiled staticlib where LogosModule.cmake's # LOGOS_MODULE_RUST_STATIC_LIBS block finds it (the plugin build's lib/). rustStaging = lib.optionalString isRustModule '' mkdir -p lib cp ${rustStaticLib}/lib/lib${rustStaticName}.a lib/ ''; # Backend arguments for a given external-lib variant ("default" or # "portable"). Shared by buildVariant (compiles the plugin) and the # generate output (snapshots the post-codegen source tree) so both use the # identical preConfigure / deps / env. mkPluginArgs = variant: let externalLibs = if variant == "default" then defaultExternalLibs else mkExternalLib.buildExternalLibs { inherit pkgs config src; externalInputs = lib.mapAttrs (resolveExtInput variant) externalLibInputs; }; # rustStaging (empty for non-Rust modules) drops the compiled Rust # staticlib into lib/ before cmake, where the LOGOS_MODULE_RUST_STATIC_LIBS # block links it — the builder-driven replacement for the per-flake # buildRustPackage + cp the author used to write by hand. userPreConfigure = rustStaging + ( if builtins.isFunction preConfigure then preConfigure { inherit externalLibs; } else preConfigure); preConfigureStr = modulePreConfigure.compose { inherit config externalLibs protocolVersion; userPre = userPreConfigure; # Stage the rust-first derived .lidl before the glue codegen reads it. preCodegen = lidlStaging; fixDarwin = false; # logos-plugin-qt buildPlugin already stages external libs into lib/ copyExternals = false; }; goCmakeFlags = lib.optionals (config.go_static_lib_names != []) [ "-DLOGOS_MODULE_GO_STATIC_LIBS=${lib.concatStringsSep ";" config.go_static_lib_names}" ]; # LOGOS_API_STYLE forwards through to logos-cpp-generator and # picks which type surface the generated client wrappers # (and the umbrella LogosModules struct) expose. Mirrors the # backend's apiStyle (logos-plugin-qt buildPlugin.nix): core # universal modules are header-first cdylibs → Qt-free lp_* wrappers. # UI universal backends (type: ui_qml) are NOT modules — they derive a # Qt SimpleSource whose .rep slots are Qt-typed, so their # LogosUiPluginContext.modules() dep wrappers are Qt-typed too (the # generator default — no flag). Every other interface keeps qt. # (Only consulted in the source layout; nix builds get apiStyle from # the backend's --general-only call.) # # `config.consumer_api_style` (parseMetadata.nix — the resolved # `codegen.consumer_api_style`) is what makes this an override rather # than a pure derivation. It only ever REMOVES the flag: a # cdylib-packaged module that asks for the Qt consumer surface must # not have `lp` forced on it here. It is deliberately NOT allowed to # ADD one — the trigger condition below is character-for-character # today's, so no module that passes no flag today starts passing one # (a `cdylib` module never got this flag even though the nix backend # types it `lp`; unifying that would change every cdylib module's # derivation for a flag only the legacy source layout reads). # # There is no `--binding` counterpart here on purpose: this branch of # LogosModule.cmake never invokes logos-qt-generator at all, so it # cannot emit the origin-bound wrapper SET that the origin-bound # umbrella needs. The Qt consumer surface for a cdylib module is a # nix-build capability; the source layout keeps the one shape it can # actually produce. apiStyleCmakeFlags = if config.interface == "universal" && (config.type or "core") != "ui_qml" && config.consumer_api_style == "lp" then [ "-DLOGOS_API_STYLE=lp" ] else []; # The backend only knows about Qt + logosModule (interface.h). # SDK (generator, lib, headers) is injected via extra* args. in ({ inherit pkgs src config logosModule; postInstall = stageIncludedRuntimeFiles + postInstall; preConfigure = preConfigureStr; moduleDeps = resolvedModuleDeps; inherit externalLibs; # pkgs.jq is target-typed too and jq runs in preConfigure # (modulePreConfigure.nix:203). buildPackages == pkgs natively. extraNativeBuildInputs = extraNativeBuildInputs ++ buildPkgs ++ [ logosSdkBuild logosQtGenerator logosQtHostGenerator pkgs.buildPackages.jq ]; extraBuildInputs = extraBuildInputs ++ runtimePkgs ++ [ logosQtSdk logosQtHost logosProtocolPkg ] # A Rust staticlib's vendored C may want winpthreads: with # reachable, aws-lc-sys compiles aws-lc's thread_pthread.c and the # plugin link then needs pthread_rwlock_*, pthread_once, sched_yield. # aws-lc assumes the standard mingw environment, where winpthreads is # simply present; nixpkgs builds mingw against mcfgthread, so it is a # separate package on no default path. As a buildInput its lib/ lands # on NIX_LDFLAGS, which is what lets the `pthread` named by # LogosModule.cmake's WIN32 branch resolve. # # Cross Rust modules only, and free for the ones that do not need it: # ld pulls archive members on demand, so a module referencing no # pthread symbol links exactly as before. ++ lib.optional (isRustModule && rustCrossTarget != null) pkgs.windows.pthreads; # Qt splits each module's TOOLS (repc, moc, qmltyperegistrar) into a # SEPARATE package that must run on the BUILD machine. Without these # flags find_package(Qt6 COMPONENTS RemoteObjects) fails on a # thoroughly misleading message -- it names Qt6RemoteObjects, but the # TARGET config is found fine; it is Qt6RemoteObjectsTools that is # missing. logos-nix's Windows overlay exposes the flags; the # attribute is absent (and so `or []`) on a native build, which is why # this needs no isWindows guard. extraCmakeFlags = (pkgs.logosQtCrossCmakeFlags or [ ]) ++ [ "-DLOGOS_CPP_SDK_ROOT=${logosSdk}" "-DLOGOS_QT_SDK_ROOT=${logosQtSdk}" "-DLOGOS_QT_HOST_ROOT=${logosQtHost}" "-DLOGOS_PROTOCOL_ROOT=${logosProtocolPkg}" "-DLOGOS_VIEW_TEMPLATE_DIR=${viewTemplates}" ] ++ goCmakeFlags ++ apiStyleCmakeFlags ++ lib.optionals isRustModule [ "-DLOGOS_MODULE_RUST_STATIC_LIBS=${rustStaticName}" ]; extraEnv = { LOGOS_CPP_SDK_ROOT = "${logosSdk}"; LOGOS_QT_SDK_ROOT = "${logosQtSdk}"; LOGOS_QT_HOST_ROOT = "${logosQtHost}"; LOGOS_PROTOCOL_ROOT = "${logosProtocolPkg}"; LOGOS_MODULE_BUILDER_ROOT = builderCmakeRoot; # Both channels on purpose, not belt-and-braces: LogosModule.cmake # prefers the cache variable above and falls back to this env var, # and the two reach different consumers. The flag is what a nix # buildPlugin's cmakeConfigurePhase sees; the env var is what a # hand-run `cmake` in a dev shell sees, where no cmakeFlags exist. LOGOS_VIEW_TEMPLATE_DIR = "${viewTemplates}"; }; } # Only pass interfaceDeps when the module declares any — keeps existing # dependency-only modules buildable against a backend that predates the # interface-dependencies feature (graceful degradation). A Rust module's # deps ALSO feed the Rust generator (rustDepFlags) for the typed # modules()/bind() it actually calls; they still go to the C++ backend # too so the generated umbrella (logos_sdk.h, emitted from # metadata.dependencies) finds each dep's api header and compiles. // lib.optionalAttrs (config.interface_dependencies != []) { interfaceDeps = resolvedInterfaceDeps; } # LIDL-based concrete deps → `--dep` flags (generate from the dep's # published LIDL, no dep plugin build). Gated so a backend that predates # this feature still builds (such deps then fall through unresolved). // lib.optionalAttrs (staticDeps != []) { inherit staticDeps; }); # Compile the plugin for a variant (delegated to the backend). buildVariant = variant: selectedBackend.buildPlugin (mkPluginArgs variant); moduleLib = buildVariant "default"; moduleLibPortable = if hasVariants then buildVariant "portable" else null; # Ready-to-build source tree: the backend runs every generator the build # runs, then snapshots the result (module source + generated_code/) instead # of compiling. Same args as the default plugin build, so the emitted tree # is exactly what a real build generates. Built from the module's # `nix develop` shell (which exports LOGOS_*_ROOT) without re-running codegen. moduleGenerate = selectedBackend.generate (mkPluginArgs "default"); # Two header variants per module — Qt-typed and lp (Qt-free, # logos-protocol C ABI). Each is its own Nix derivation, so a # downstream module only realises the one its `--api-style` actually # consumes. The lp variant lets a core universal (header-first cdylib) # module copy a Qt-free typed wrapper for a LEGACY dependency that # publishes no `.lidl` (the wrapper is generated by introspecting the # dep's built plugin, so it works regardless of how the dep was # authored). Default output (`include`) stays the Qt variant for # backward compatibility with consumers that read `${dep}/include`. # (A third `std` variant — std-typed signatures but still marshalling # through QVariant, so never actually Qt-free — used to be built here. # `buildPlugin.nix` only ever selects "qt" or "lp", so it had no # consumer; it was retired rather than rebuilt for every module.) # The contract buildHeaders falls back to when it cannot introspect the # built plugin (cross-compilation — a Linux builder cannot load a PE). # Preference order: # 1. this module's published `lidl` output (universal + cdylib), then # 2. a contract committed at src/.lidl. # (2) is the escape hatch for handcrafted Qt / `interface: "legacy"` # modules, which derive no contract from their sources. It is deliberately # NOT folded into `moduleLidl` below: publishing a `lidl` output flips # every downstream consumer of this module from the transitional # header-copy path onto `--dep` (see depIsLidl above), which would change # native builds across the tree. This binding is consumed by buildHeaders # ALONE, and buildHeaders only reads it when cross-compiling. committedLidl = src + "/src/${config.name}.lidl"; headerContractLidl = if moduleLidl != null then "${moduleLidl}/${config.name}.lidl" else if builtins.pathExists committedLidl then "${committedLidl}" else null; # `qtGenerator` is what lets the QT variant come from the module's # CONTRACT (logos-qt-generator --backend consumer) instead of from # introspecting the compiled plugin. Both tools are passed for a pure # tool role -- the backend picks the one its selected emitter needs and # puts only that one on PATH. Omitting qtGenerator does not break the # build; it silently demotes every contract-bearing module back to the # legacy Qt emitter, which is why buildHeaders shouts about that case # rather than just falling back. moduleIncludeQt = selectedBackend.buildHeaders { inherit pkgs src config; # buildHeaders uses these ONLY to put a generator on PATH -- a pure # tool role, hence the BUILD-platform variants under cross. logosSdk = logosSdkBuild; qtGenerator = logosQtGenerator; pluginLib = moduleLib; apiStyle = "qt"; contractLidl = headerContractLidl; }; moduleIncludeLp = selectedBackend.buildHeaders { inherit pkgs src config; # No qtGenerator: logos-qt-generator has no lp backend, so the lp # wrapper still comes from logos-cpp-generator's (non-legacy-Qt) lp # emitter, byte-for-byte as before. logosSdk = logosSdkBuild; pluginLib = moduleLib; apiStyle = "lp"; contractLidl = headerContractLidl; }; # Publish this module's interface as LIDL — the language-neutral contract # a consumer turns into typed `modules().` bindings WITHOUT building # this module's plugin (source → LIDL → C++). Cheap: runs only the C++ # frontend (`--header-to-lidl`) over the impl header; no Qt/plugin compile. # Produced for universal modules; the impl header + class come from the # same convention `universalCodegen` uses (`codegen.impl_*` or defaults). lidlImplClass = config.codegen.impl_class or (modulePreConfigure.defaultImplClassFromName config.name); lidlIhRaw = config.codegen.impl_header or "${config.name}_impl.h"; lidlImplHeaderRel = if lib.hasInfix "/" lidlIhRaw then lidlIhRaw else "src/${lidlIhRaw}"; moduleLidl = if config.interface == "universal" then pkgs.runCommand "logos-${config.name}-lidl" { nativeBuildInputs = [ logosSdkBuild ]; } '' mkdir -p $out logos-cpp-generator --header-to-lidl "${src}/${lidlImplHeaderRel}" \ --impl-class "${lidlImplClass}" \ --metadata "${configFile}" \ -o "$out/${config.name}.lidl" '' # Cdylib modules publish their .lidl as the interface (whether the impl # is Rust or C++), so consumers generate typed bindings from it like for # any other dep. Contract-first modules copy the committed file; a # rust-first module publishes the .lidl DERIVED from its trait. else if rustDeriveMode then pkgs.runCommand "logos-${config.name}-lidl" {} '' mkdir -p $out cp "${derivedLidl}/${config.name}.lidl" "$out/${config.name}.lidl" '' else if config.interface == "cdylib" && config.codegen ? lidl then pkgs.runCommand "logos-${config.name}-lidl" {} '' mkdir -p $out cp "${src}/${config.codegen.lidl}" "$out/${config.name}.lidl" '' else null; # Combined package — copies the Qt-typed headers (backward # compat). The `//` merge exposes src + version on the derivation # so downstream bundlers (nix-bundle-lgx) can locate metadata.json. combined = (pkgs.runCommand "logos-${config.name}-module" {} '' mkdir -p $out/lib $out/include # Copy library files (not symlinks) if [ -d "${moduleLib}/lib" ]; then cp -rL ${moduleLib}/lib/* $out/lib/ fi # Copy include files (not symlinks) — use find to avoid nullglob issues if [ -d "${moduleIncludeQt}/include" ] && [ -n "$(find ${moduleIncludeQt}/include -maxdepth 1 -not -name '.*' -not -path ${moduleIncludeQt}/include -print -quit)" ]; then cp -rL ${moduleIncludeQt}/include/* $out/include/ fi '') // { inherit src; version = config.version; }; in { # Individual outputs (e.g., nix build .#chat-lib) "${config.name}-lib" = moduleLib; "${config.name}-include" = moduleIncludeQt; "${config.name}-headers-qt" = moduleIncludeQt; "${config.name}-headers-lp" = moduleIncludeLp; # Short aliases (e.g., nix build .#lib) lib = moduleLib; include = moduleIncludeQt; headers-qt = moduleIncludeQt; headers-lp = moduleIncludeLp; # Default package - combined lib + include (nix build) default = combined; # Ready-to-build codebase: all code generators run, emitted as a source # tree (nix build .#generate). Build it from `nix develop` — no generator # re-runs (LogosModule.cmake consumes the pre-populated generated_code/). generate = moduleGenerate; "${config.name}-generate" = moduleGenerate; } // lib.optionalAttrs (moduleLibPortable != null) { "${config.name}-lib-portable" = moduleLibPortable; lib-portable = moduleLibPortable; } // lib.optionalAttrs (moduleLidl != null) { # Published LIDL contract — consumers generate bindings from this without # building the plugin. Cheap (frontend only). Absent for non-universal # modules, so consumers fall back to the header-copy path for those. "${config.name}-lidl" = moduleLidl; lidl = moduleLidl; } ); # Development shell (delegates to backend for deps) devShells = forAllSystems (system: let pkgs = common.mkPkgs system; logosSdk = logos-cpp-sdk.packages.${system}.default; # Build-platform half of the SDK. logos-cpp-generator is invoked by BARE # NAME from a build phase (logos-plugin-qt/lib/buildPlugin.nix:145), so it # must run on the builder. Under cross, packages.x86_64-windows.default # carries no runnable generator at all -- logos-cpp-sdk/nix/bin.nix:39 # silently skips the mingw .exe -- hence "command not found". # # `logosSdk` deliberately stays TARGET-typed: it is ALSO the header and # CMake-package root passed to LOGOS_CPP_SDK_ROOT, and those must keep # coming from the Windows set. Splitting the two roles is the whole point; # pointing the headers at the build system would produce a build that # SUCCEEDS while linking the wrong architecture. # # buildSystemFor is the identity on every native system, so this is a # no-op off the Windows target. logosSdkBuild = logos-cpp-sdk.packages.${common.buildSystemFor system}.default; logosQtSdk = logos-qt-sdk.packages.${system}.default; # Same repoint in the dev shell: LOGOS_QT_HOST_ROOT below. logosQtHost = logos-plugin-qt.packages.${system}.logos-qt-host; # The Qt glue generator (universal/cdylib/ui backends) — Qt code is # the Qt layer's product; logos-cpp-generator keeps Qt-free outputs. logosQtGenerator = logos-qt-sdk.packages.${common.buildSystemFor system}.logos-qt-generator; # The cdylib Qt-plugin glue generator lives in logos-plugin-qt (the Qt # plugin BACKEND owns the glue; the SDK does not). logos-qt-sdk still # ships an older copy of the SAME emitter, and calling that one is not a # compile error — it silently emits STALE glue. That is how a # host-services grant went undelivered while every build stayed green. logosQtHostGenerator = logos-plugin-qt.packages.${common.buildSystemFor system}.logos-qt-host-generator; # The four LogosView*.in templates logos_module(REP_FILE ...) instantiates. # They live in logos-view-module (the ui_qml authoring flavour), NOT in # the plugin backend any more, and cmake/LogosModule.cmake here refuses to # guess — it hard-errors unless handed LOGOS_VIEW_TEMPLATE_DIR. # # buildSystemFor, not plain ${system}: these are text files with no # platform dimension, and logos-view-module publishes only the four # NATIVE systems, so `packages.x86_64-windows` would EVAL-fail on the # Windows leg — a failure that is invisible until someone crosses. viewTemplates = logos-view-module.packages.${common.buildSystemFor system}.logos-view-templates; logosProtocolPkg = logos-protocol.packages.${system}.default; logosModule = logos-module.packages.${system}.default; backendShell = selectedBackend.devShellInputs pkgs { inherit logosModule; }; buildPkgs = map (getPkg pkgs) config.nix_packages.build; runtimePkgs = map (getPkg pkgs) config.nix_packages.runtime; # Resolve external lib inputs for this system so we can point cmake directly # at their Nix store paths via LOGOS_EXT_ROOT_, skipping the ./lib/ staging copy. resolveExtInputDev = name: value: if builtins.isAttrs value && value ? input then let pkgName = (value.packages or {}).default or "default"; in value.input.packages.${system}.${pkgName} or null else value.packages.${system}.default or value; devExternalLibs = lib.filterAttrs (_: v: v != null && lib.isDerivation v) (lib.mapAttrs resolveExtInputDev externalLibInputs); in { default = pkgs.mkShell { nativeBuildInputs = backendShell.nativeBuildInputs ++ buildPkgs ++ [ logosSdkBuild ]; buildInputs = backendShell.buildInputs ++ runtimePkgs ++ lib.attrValues devExternalLibs; shellHook = '' ${backendShell.shellHook} export LOGOS_CPP_SDK_ROOT="${logosSdk}" export LOGOS_QT_SDK_ROOT="${logos-qt-sdk.packages.${system}.default}" export LOGOS_QT_HOST_ROOT="${logosQtHost}" export LOGOS_PROTOCOL_ROOT="${logos-protocol.packages.${system}.default}" export LOGOS_MODULE_BUILDER_ROOT="${builderCmakeRoot}" # The plugin backend used to export this from its own devShellInputs # shellHook (spliced in above). It stopped when the templates left it, # and nothing in that repo can catch the regression — a missing value # here surfaces only when someone hand-runs cmake on a REP_FILE module. export LOGOS_VIEW_TEMPLATE_DIR="${viewTemplates}" ${lib.concatStringsSep "\n" (lib.mapAttrsToList (name: drv: '' export LOGOS_EXT_ROOT_${lib.toUpper name}="${drv}" '') devExternalLibs)} echo "Logos ${config.name} module development environment" echo "LOGOS_CPP_SDK_ROOT: $LOGOS_CPP_SDK_ROOT" echo "LOGOS_MODULE_ROOT: $LOGOS_MODULE_ROOT" echo "LOGOS_MODULE_BUILDER_ROOT: $LOGOS_MODULE_BUILDER_ROOT" ''; }; } ); # LGX package outputs (nix-bundle-lgx provided by the builder) nixBundleLgx = nix-bundle-lgx; optionalLgx = { packages = forAllSystems (system: let bundleLgx = nixBundleLgx.bundlers.${system}.default; bundleLgxPortable = nixBundleLgx.bundlers.${system}.portable; installDev = nix-bundle-logos-module-install.bundlers.${system}.dev; installPortable = nix-bundle-logos-module-install.bundlers.${system}.portable; moduleLib = packages.${system}.lib; # Use the portable-linked plugin for lgx-portable when available moduleLibForPortable = packages.${system}.lib-portable or moduleLib; in { lgx = bundleLgx moduleLib; install = installDev moduleLib; lgx-portable = bundleLgxPortable moduleLibForPortable; install-portable = installPortable moduleLibForPortable; } ); }; # Resolve the standalone app: explicit override > built-in from module-builder resolvedStandalone = if logosStandalone != null then logosStandalone else if config.type == "ui" then logos-standalone-app else null; optionalApps = if resolvedStandalone == null then {} else { apps = forAllSystems (system: let pkgs = common.mkPkgs system; # Collect all module dependencies (direct + transitive) for bundling allDeps = common.collectAllModuleDeps system flakeInputs config.dependencies; in { default = mkStandaloneApp { inherit pkgs; standalone = resolvedStandalone.packages.${system}.default; plugin = packages.${system}.default; metadataFile = configFile; dirName = "logos-${config.name}-plugin-dir"; format = "qt-plugin"; moduleDeps = allDeps; }; } ); }; # Merge LGX outputs into packages mergedPackages = lib.mapAttrs (system: sysPkgs: sysPkgs // (optionalLgx.packages.${system} or {}) ) packages; # Build unit tests — explicit config wins, otherwise auto-detect tests/CMakeLists.txt mkTests = import ./mkLogosModuleTests.nix { inherit nixpkgs lib common parseMetadata; inherit logos-cpp-sdk logos-protocol logos-qt-sdk logos-plugin-qt; logos-test-framework = logos-test-framework; }; resolvedTests = if tests != null then tests else if builtins.pathExists (src + "/tests/CMakeLists.txt") then { dir = src + "/tests"; } else null; testChecks = if resolvedTests == null then {} else mkTests { inherit src flakeInputs externalLibInputs; configFile = configFile; testDir = resolvedTests.dir; mockCLibs = resolvedTests.mockCLibs or []; preConfigure = resolvedTests.preConfigure or preConfigure; extraBuildInputs = resolvedTests.extraBuildInputs or []; extraCmakeFlags = resolvedTests.extraCmakeFlags or []; }; optionalTests = if testChecks == {} then {} else { checks = testChecks; }; # Also expose unit-tests as a package so `nix build .#unit-tests` works testPackages = if testChecks == {} then {} else lib.mapAttrs (_system: sysChecks: { unit-tests = sysChecks.unit-tests; } ) testChecks; finalPackages = lib.mapAttrs (system: sysPkgs: sysPkgs // (testPackages.${system} or {}) ) mergedPackages; in { packages = finalPackages; inherit devShells config; metadataJson = builtins.readFile configFile; } // optionalApps // optionalTests