diff --git a/synapse/cli/gateware.py b/synapse/cli/gateware.py index b9627ba..fb9491e 100644 --- a/synapse/cli/gateware.py +++ b/synapse/cli/gateware.py @@ -116,23 +116,123 @@ def build_license_docker_args( return args -# The gateware project lives in this subdir of a peripheral repo, by -# convention shared with the structured build below and the pass-through's -# implicit-project workdir redirect. -_GATEWARE_PROJECT_SUBDIR = "src/gateware" +# Gateware sources live under this subdir of a peripheral repo, by convention +# shared with the structured build below and the pass-through's implicit-project +# workdir redirect. +# +# Two layouts are supported: +# +# - **Single-project** (the original): ``src/gateware/peripheral.yaml`` — the +# subdir *is* the SDK project, and there is nothing to choose. +# - **Per-profile**: ``src/gateware//peripheral.yaml``, one project per +# target profile (e.g. ``via-devkit``, ``nerv512u-devkit``). A profile's +# generated top wrapper, seed constraints, and encrypted transport bundle are +# mutually exclusive with any other profile's — same filenames, different +# contents — so they cannot share a directory. +# +# ``resolve_gateware_project`` picks between them; every caller that needs to +# name a project (build, clean, pass-through workdir) routes through it so the +# choice is made in exactly one place. +GATEWARE_ROOT_SUBDIR = "src/gateware" + +# Honoured everywhere, including ``peripherals gateware `` — that +# dispatcher captures its tail with ``argparse.REMAINDER``, so a synapsectl-side +# ``--profile`` flag would be swallowed and forwarded to the SDK instead. An +# env var is the only selector that can reach it. +PROFILE_ENV_VAR = "SYNAPSE_GATEWARE_PROFILE" + + +class GatewareProfileError(RuntimeError): + """Raised when the gateware project to act on can't be determined.""" + + +def discover_gateware_profiles(peripheral_dir: str) -> list[str]: + """Return the per-profile gateware project names, sorted. + + A directory counts as a profile project only if it holds a + ``peripheral.yaml``, so build outputs and stray directories under + ``src/gateware/`` are never mistaken for one. + """ + root = Path(peripheral_dir) / GATEWARE_ROOT_SUBDIR + if not root.is_dir(): + return [] + return sorted( + entry.name + for entry in root.iterdir() + if entry.is_dir() and (entry / "peripheral.yaml").is_file() + ) + + +def resolve_gateware_project( + peripheral_dir: str, + profile: str | None = None, + env: Mapping[str, str] = os.environ, +) -> str: + """Return the repo-relative subdir of the gateware project to act on. -_SDK_BUILD_CMD = f"axon-peripheral-sdk build --project {_GATEWARE_PROJECT_SUBDIR}" + Resolution order: + + 1. An explicit *profile* (``--profile``), else ``$SYNAPSE_GATEWARE_PROFILE``. + Must name a ``src/gateware//`` project or this raises. + 2. A single-project repo (``src/gateware/peripheral.yaml``) — the original + layout, still resolved with no flag. + 3. Exactly one per-profile project — unambiguous, so no flag needed. + + Anything else raises :class:`GatewareProfileError`. In particular a repo + with two or more profiles and no selection is an error rather than a + guess: the profiles differ in clock rate and FPGA part, so picking one + arbitrarily would produce artifacts that are wrong in ways that don't + surface until they're on hardware. + """ + selected = profile or env.get(PROFILE_ENV_VAR) or None + profiles = discover_gateware_profiles(peripheral_dir) + + if selected: + if selected not in profiles: + known = ", ".join(profiles) if profiles else "none found" + raise GatewareProfileError( + f"No gateware project for profile {selected!r} at " + f"{GATEWARE_ROOT_SUBDIR}/{selected}/peripheral.yaml. " + f"Available: {known}." + ) + return f"{GATEWARE_ROOT_SUBDIR}/{selected}" + + if (Path(peripheral_dir) / GATEWARE_ROOT_SUBDIR / "peripheral.yaml").is_file(): + return GATEWARE_ROOT_SUBDIR + + if len(profiles) == 1: + return f"{GATEWARE_ROOT_SUBDIR}/{profiles[0]}" + + if not profiles: + raise GatewareProfileError( + f"No gateware project found: expected " + f"{GATEWARE_ROOT_SUBDIR}/peripheral.yaml or " + f"{GATEWARE_ROOT_SUBDIR}//peripheral.yaml under " + f"{peripheral_dir}." + ) + + raise GatewareProfileError( + f"This repo has more than one gateware profile ({', '.join(profiles)}) " + f"and no profile was selected. Pass --profile , or set " + f"{PROFILE_ENV_VAR}=." + ) def run_gateware_build( peripheral_dir: str, image_tag: str, env: Mapping[str, str] = os.environ, + project_subdir: str = GATEWARE_ROOT_SUBDIR, ) -> str: """Invoke ``axon-peripheral-sdk build`` inside the gateware container. + *project_subdir* is the repo-relative gateware project to build, as + returned by :func:`resolve_gateware_project`. The bind-mount stays the repo + root either way, so a per-profile project can still reference shared + sources above it (``../axon_test_source_peripheral.sv``). + Returns the absolute path to the newest ``sdk_*_extracted.bit`` emitted - under ``/src/gateware/build/bitstreams/``. This is the + under ``//build/bitstreams/``. This is the *extracted* bitstream variant the SDK writes alongside the raw ``sdk_*.bit``; the extracted form is what gets flashed to the probe, and it carries its own same-stem ``.summary.json`` (same schema as the raw @@ -163,14 +263,13 @@ def run_gateware_build( image_tag, "/bin/bash", "-lc", - _SDK_BUILD_CMD, + f"axon-peripheral-sdk build --project {project_subdir}", ] subprocess.run(argv, check=True) bit_glob = os.path.join( abs_peripheral_dir, - "src", - "gateware", + *project_subdir.split("/"), "build", "bitstreams", "sdk_*_extracted.bit", @@ -179,7 +278,7 @@ def run_gateware_build( if not matches: raise FileNotFoundError( "axon-peripheral-sdk build completed but no sdk_*_extracted.bit was " - "emitted under src/gateware/build/bitstreams/" + f"emitted under {project_subdir}/build/bitstreams/" ) matches.sort(key=os.path.getmtime, reverse=True) @@ -336,6 +435,7 @@ def _gateware_passthrough( peripheral_dir: str, license_args: Sequence[str], gateware_image_tag: str, + project_subdir: str | None = None, ) -> int: """Forward ``argv`` verbatim to ``axon-peripheral-sdk`` inside the container. @@ -360,19 +460,25 @@ def _gateware_passthrough( abs_peripheral_dir = os.path.abspath(peripheral_dir) # When invoked from a peripheral project root (manifest.json present) that - # has a gateware subproject, run the SDK with its cwd inside src/gateware so + # has a gateware subproject, run the SDK with its cwd inside that project so # every project-scoped verb resolves peripheral.yaml from its cwd default -- # including verbs with no --project flag (validate/regenerate/add-peripheral). # The bind-mount stays the repo root, so `build` still sees the whole repo. # The verb is never inspected: this is purely a directory-driven decision, # so the pass-through keeps forwarding argv verbatim with no verb allowlist. + # + # *project_subdir* is the resolved project (see resolve_gateware_project). + # None means the caller could not resolve one -- typically a multi-profile + # repo with no profile selected. Rather than guess, leave cwd at the repo + # root and let the SDK resolve the project itself: the user can still say + # `--project src/gateware/`, which reaches the SDK verbatim. workdir = "/home/workspace" - if os.path.isfile( - os.path.join(abs_peripheral_dir, "manifest.json") - ) and os.path.isdir( - os.path.join(abs_peripheral_dir, *_GATEWARE_PROJECT_SUBDIR.split("/")) + if ( + project_subdir + and os.path.isfile(os.path.join(abs_peripheral_dir, "manifest.json")) + and os.path.isdir(os.path.join(abs_peripheral_dir, *project_subdir.split("/"))) ): - workdir = f"/home/workspace/{_GATEWARE_PROJECT_SUBDIR}" + workdir = f"/home/workspace/{project_subdir}" # Allocate a pseudo-TTY when our own stdout is a terminal so the SDK's # rich/typer output keeps its colors (inside a plain `docker run` pipe the diff --git a/synapse/cli/peripherals.py b/synapse/cli/peripherals.py index 2edeace..ea36322 100644 --- a/synapse/cli/peripherals.py +++ b/synapse/cli/peripherals.py @@ -19,6 +19,7 @@ import argparse import json import os +import shlex import shutil import subprocess import sys @@ -61,6 +62,13 @@ def _add_half_subcommands(parent_parser, *, func, action_label, extra_args): the old half-selector flags. A bare parent command (no leaf chosen) prints its own help. *action_label* is the verb phrase used in each leaf's help line ("Build/package", "Build/deploy"). + + Every leaf also carries ``--profile``, which selects the gateware project + in a repo that ships one per target profile (``src/gateware//``). + It is wired here rather than per-command because it steers both halves: the + gateware half picks which project to build, and the driver half forwards it + to CMake as ``-DAXON_TARGET_PROFILE`` so profile-dependent constants (clock + rates, part numbers) compile against the devkit the bitstream targets. """ targets = { "driver": "only the driver .so (skips the gateware container)", @@ -76,6 +84,17 @@ def _add_half_subcommands(parent_parser, *, func, action_label, extra_args): default=".", help="Path to the peripheral plugin directory (defaults to cwd)", ) + leaf.add_argument( + "--profile", + type=str, + default=None, + help=( + "Target profile to build for, naming a gateware project at " + "src/gateware// (e.g. via-devkit, nerv512u-devkit). " + "Required when the repo has more than one; may also be set via " + f"${gateware.PROFILE_ENV_VAR}." + ), + ) extra_args(leaf) leaf.set_defaults(func=func, half=half) parent_parser.set_defaults(func=lambda _: parent_parser.print_help()) @@ -205,9 +224,18 @@ def _expected_so_filename(manifest: dict) -> str: def build_peripheral_so( - peripheral_dir: str, plugin_name: str, so_filename: str, clean: bool = False + peripheral_dir: str, + plugin_name: str, + so_filename: str, + clean: bool = False, + profile: Optional[str] = None, ) -> bool: - """Cross-compile *plugin_name* into a .so inside its SDK container.""" + """Cross-compile *plugin_name* into a .so inside its SDK container. + + When *profile* is set it is passed to CMake as ``-DAXON_TARGET_PROFILE``, + letting the driver compile against the target devkit's parameters. Left + unset for single-profile repos, whose CMakeLists never reads the variable. + """ console.print(f"[yellow]Building peripheral plugin: {plugin_name}...[/yellow]") so_path = os.path.join(peripheral_dir, "build/aarch64", so_filename) @@ -263,10 +291,18 @@ def build_peripheral_so( ) console.print("[blue]Running cmake build...[/blue]") + # Injected into both configure branches (preset and legacy) so the choice + # of profile doesn't depend on which one a given repo happens to take. + # shlex.quote keeps a hostile profile name from breaking out of the + # `bash -c` string; the value reaches us from --profile or the environment. + profile_flag = ( + f" -DAXON_TARGET_PROFILE={shlex.quote(profile)}" if profile else "" + ) build_cmd_str = ( "cd /home/workspace && " "if [ -f CMakePresets.json ]; then " - "cmake --preset=dynamic-aarch64 -DVCPKG_TARGET_TRIPLET='arm64-linux-dynamic-release' && " + "cmake --preset=dynamic-aarch64 -DVCPKG_TARGET_TRIPLET='arm64-linux-dynamic-release'" + f"{profile_flag} && " "cmake --build --preset=cross-release -j$(nproc); " "else " "export VCPKG_DEFAULT_TRIPLET=arm64-linux-dynamic-release && " @@ -276,7 +312,7 @@ def build_peripheral_so( "-DVCPKG_INSTALLED_DIR=${VCPKG_ROOT}/build/host/vcpkg_installed " "-DBUILD_SHARED_LIBS=ON " "-DCMAKE_BUILD_TYPE=Release " - "-DBUILD_FOR_ARM64=ON && " + f"-DBUILD_FOR_ARM64=ON{profile_flag} && " "cmake --build build/aarch64 -j$(nproc); " "fi" ) @@ -703,14 +739,19 @@ def build_gateware_deb( # --------------------------------------------------------------------------- -def _clean_gateware_tree(peripheral_dir: str, gateware_image_tag: str) -> None: - """Wipe ``/src/gateware/build/`` via a docker run. +def _clean_gateware_tree( + peripheral_dir: str, gateware_image_tag: str, project_subdir: str +) -> None: + """Wipe ``//build/`` via a docker run. Mirrors the driver-side clean in :func:`build_peripheral_so`: it runs the rm inside the gateware container so the host user does not need to chown - files written as the in-container ``dev`` user. + files written as the in-container ``dev`` user. Scoped to the selected + project so cleaning one profile leaves the other profile's build intact. """ - console.print("[yellow]Cleaning gateware build directory...[/yellow]") + console.print( + f"[yellow]Cleaning gateware build directory ({project_subdir}/build)...[/yellow]" + ) clean_cmd = [ "docker", "run", @@ -720,7 +761,7 @@ def _clean_gateware_tree(peripheral_dir: str, gateware_image_tag: str) -> None: gateware_image_tag, "/bin/bash", "-c", - "cd /home/workspace && rm -rf src/gateware/build || true", + f"cd /home/workspace && rm -rf {shlex.quote(project_subdir)}/build || true", ] try: subprocess.run(clean_cmd, check=True, cwd=peripheral_dir) @@ -728,7 +769,7 @@ def _clean_gateware_tree(peripheral_dir: str, gateware_image_tag: str) -> None: console.print("[yellow]Warning: gateware clean failed; continuing.[/yellow]") -def _run_gateware_half(peripheral_dir: str) -> Optional[str]: +def _run_gateware_half(peripheral_dir: str, project_subdir: str) -> Optional[str]: """Run the gateware build half; return the emitted ``.bit`` path or None.""" try: image_tag = build_docker_image( @@ -741,7 +782,9 @@ def _run_gateware_half(peripheral_dir: str) -> Optional[str]: return None try: - return gateware.run_gateware_build(peripheral_dir, image_tag) + return gateware.run_gateware_build( + peripheral_dir, image_tag, project_subdir=project_subdir + ) except LicenseUnsetError as exc: console.print(f"[bold red]Error:[/bold red] {exc}") return None @@ -760,12 +803,21 @@ def _gateware_usb_pid(bit_path: str) -> Optional[int]: def _build_debs( - peripheral_dir: str, manifest: dict, half: str, *, clean: bool = False + peripheral_dir: str, + manifest: dict, + half: str, + *, + clean: bool = False, + profile: Optional[str] = None, ) -> Optional[list]: """Build the requested halves; return built .deb paths or None on failure. Driver deb first, then the -gateware deb — deploy streams them in this order so the plugin lands before its gateware shows up as flashable. + + The gateware project is resolved once, up front, even for a driver-only + build: the driver's compiled-in profile has to agree with the gateware it + will drive, and failing here beats discovering the mismatch on hardware. """ plugin_name = manifest["name"] version = manifest.get("version", "0.1.0") @@ -774,6 +826,22 @@ def _build_debs( dist_dir = os.path.join(peripheral_dir, "dist") debs: list = [] + # Resolve the target profile from the selected gateware project. A + # single-project repo resolves to plain "src/gateware" and yields no + # profile, which is what pre-multi-profile repos expect. + try: + project_subdir = gateware.resolve_gateware_project(peripheral_dir, profile) + except gateware.GatewareProfileError as exc: + console.print(f"[bold red]Error:[/bold red] {exc}") + return None + resolved_profile = ( + project_subdir.split("/")[-1] + if project_subdir != gateware.GATEWARE_ROOT_SUBDIR + else None + ) + if resolved_profile: + console.print(f"[bold]Target profile:[/bold] [yellow]{resolved_profile}[/yellow]") + if do_gateware and clean: try: gateware_image_tag = build_docker_image( @@ -784,12 +852,16 @@ def _build_debs( f"[bold red]Error:[/bold red] Failed to build gateware Docker image: {exc}" ) return None - _clean_gateware_tree(peripheral_dir, gateware_image_tag) + _clean_gateware_tree(peripheral_dir, gateware_image_tag, project_subdir) if do_driver: so_filename = _expected_so_filename(manifest) if not build_peripheral_so( - peripheral_dir, plugin_name, so_filename, clean=clean + peripheral_dir, + plugin_name, + so_filename, + clean=clean, + profile=resolved_profile, ): return None so_path = os.path.join(peripheral_dir, "build/aarch64", so_filename) @@ -803,7 +875,7 @@ def _build_debs( debs.append(deb) if do_gateware: - bit_path = _run_gateware_half(peripheral_dir) + bit_path = _run_gateware_half(peripheral_dir, project_subdir) if bit_path is None: return None usb_pid = _gateware_usb_pid(bit_path) @@ -850,7 +922,11 @@ def build_cmd(args) -> None: ) debs = _build_debs( - peripheral_dir, manifest, getattr(args, "half", "both"), clean=args.clean + peripheral_dir, + manifest, + getattr(args, "half", "both"), + clean=args.clean, + profile=getattr(args, "profile", None), ) if debs is None: return @@ -915,7 +991,9 @@ def deploy_cmd(args) -> None: f"[bold]Deploying peripheral plugin:[/bold] [yellow]{manifest['name']}[/yellow]" ) - debs = _build_debs(peripheral_dir, manifest, half) + debs = _build_debs( + peripheral_dir, manifest, half, profile=getattr(args, "profile", None) + ) if debs is None: return deb_packages = debs @@ -1012,11 +1090,38 @@ def gateware_cmd(args) -> None: ) sys.exit(1) + # Best-effort project resolution for the cwd redirect. REMAINDER owns every + # token after `gateware`, so there is no --profile to read here; only + # $SYNAPSE_GATEWARE_PROFILE can select one. An unresolvable project (most + # often a multi-profile repo with nothing selected) is NOT fatal: the SDK + # runs from the repo root and the user's own `--project` reaches it + # verbatim. Hint at both escapes rather than failing the command. + try: + project_subdir = gateware.resolve_gateware_project(peripheral_dir) + except gateware.GatewareProfileError as exc: + gateware_root = Path(peripheral_dir) / gateware.GATEWARE_ROOT_SUBDIR + if gateware_root.is_dir() and not gateware.discover_gateware_profiles( + peripheral_dir + ): + # src/gateware/ exists but holds no project yet. Redirect anyway -- + # this is the scaffolding case, where `gateware new + # --target ` is meant to create the project *inside* + # src/gateware/, and cwd is what decides where it lands. + project_subdir = gateware.GATEWARE_ROOT_SUBDIR + else: + project_subdir = None + console.print( + f"[yellow]Note:[/yellow] {exc} Running the SDK from the repo " + f"root; pass `--project {gateware.GATEWARE_ROOT_SUBDIR}/` " + f"or set {gateware.PROFILE_ENV_VAR} to scope it to one project." + ) + sys.exit( gateware._gateware_passthrough( argv=list(args.argv), peripheral_dir=peripheral_dir, license_args=license_args, gateware_image_tag=tags["gateware"], + project_subdir=project_subdir, ) ) diff --git a/synapse/tests/cli/test_gateware_profiles.py b/synapse/tests/cli/test_gateware_profiles.py new file mode 100644 index 0000000..dfc2b64 --- /dev/null +++ b/synapse/tests/cli/test_gateware_profiles.py @@ -0,0 +1,200 @@ +"""Per-profile gateware project resolution. + +A peripheral repo may ship its gateware in one of two layouts: + +* **single-project** — ``src/gateware/peripheral.yaml``, the original shape; +* **per-profile** — ``src/gateware//peripheral.yaml``, one SDK project + per target profile (``via-devkit``, ``nerv512u-devkit``, …). + +The second layout exists because a profile's generated top wrapper, seed +constraints, and encrypted transport bundle collide by filename with any other +profile's while differing in content, so they cannot share a directory. + +``resolve_gateware_project`` is the single place that decides which project a +command acts on. These tests pin its contract, especially the refusal to guess +when a repo has several profiles: an arbitrary pick would silently build a +driver whose compiled-in clock rate belongs to the other devkit. +""" + +from __future__ import annotations + +import importlib + +import pytest + + +@pytest.fixture() +def gw(): + return importlib.import_module("synapse.cli.gateware") + + +def _make_repo(root, *, single=False, profiles=()): + """Materialise a peripheral repo with the requested gateware layout.""" + gateware_root = root / "src" / "gateware" + gateware_root.mkdir(parents=True, exist_ok=True) + if single: + (gateware_root / "peripheral.yaml").write_text("schema_version: 1\n") + for name in profiles: + profile_dir = gateware_root / name + profile_dir.mkdir(parents=True, exist_ok=True) + (profile_dir / "peripheral.yaml").write_text( + f"schema_version: 1\ntarget_profile: {name}\n" + ) + return root + + +# --------------------------------------------------------------------------- +# discover_gateware_profiles +# --------------------------------------------------------------------------- + + +def test_discover_returns_sorted_profile_names(gw, tmp_path): + _make_repo(tmp_path, profiles=("nerv512u-devkit", "via-devkit")) + assert gw.discover_gateware_profiles(str(tmp_path)) == [ + "nerv512u-devkit", + "via-devkit", + ] + + +def test_discover_ignores_dirs_without_a_manifest(gw, tmp_path): + """``build/`` and other scratch dirs must not read as profile projects.""" + _make_repo(tmp_path, profiles=("via-devkit",)) + (tmp_path / "src" / "gateware" / "build").mkdir() + (tmp_path / "src" / "gateware" / "build" / "bitstreams").mkdir() + assert gw.discover_gateware_profiles(str(tmp_path)) == ["via-devkit"] + + +def test_discover_on_missing_gateware_root_is_empty(gw, tmp_path): + assert gw.discover_gateware_profiles(str(tmp_path)) == [] + + +# --------------------------------------------------------------------------- +# resolve_gateware_project +# --------------------------------------------------------------------------- + + +def test_single_project_layout_resolves_without_a_profile(gw, tmp_path): + """The pre-multi-profile layout keeps working with no flag and no env.""" + _make_repo(tmp_path, single=True) + assert gw.resolve_gateware_project(str(tmp_path), None, {}) == "src/gateware" + + +def test_lone_profile_resolves_without_a_profile(gw, tmp_path): + """One profile is unambiguous, so requiring --profile would be noise.""" + _make_repo(tmp_path, profiles=("nerv512u-devkit",)) + assert ( + gw.resolve_gateware_project(str(tmp_path), None, {}) + == "src/gateware/nerv512u-devkit" + ) + + +def test_explicit_profile_selects_that_project(gw, tmp_path): + _make_repo(tmp_path, profiles=("nerv512u-devkit", "via-devkit")) + assert ( + gw.resolve_gateware_project(str(tmp_path), "via-devkit", {}) + == "src/gateware/via-devkit" + ) + + +def test_env_var_selects_when_no_explicit_profile(gw, tmp_path): + _make_repo(tmp_path, profiles=("nerv512u-devkit", "via-devkit")) + env = {gw.PROFILE_ENV_VAR: "nerv512u-devkit"} + assert ( + gw.resolve_gateware_project(str(tmp_path), None, env) + == "src/gateware/nerv512u-devkit" + ) + + +def test_explicit_profile_beats_env_var(gw, tmp_path): + _make_repo(tmp_path, profiles=("nerv512u-devkit", "via-devkit")) + env = {gw.PROFILE_ENV_VAR: "nerv512u-devkit"} + assert ( + gw.resolve_gateware_project(str(tmp_path), "via-devkit", env) + == "src/gateware/via-devkit" + ) + + +def test_explicit_profile_wins_over_a_single_project_root(gw, tmp_path): + """A repo mid-migration (root manifest + profile dirs) honours the flag.""" + _make_repo(tmp_path, single=True, profiles=("via-devkit",)) + assert ( + gw.resolve_gateware_project(str(tmp_path), "via-devkit", {}) + == "src/gateware/via-devkit" + ) + + +def test_ambiguous_repo_refuses_to_guess(gw, tmp_path): + """Two profiles and no selection must raise, never pick one.""" + _make_repo(tmp_path, profiles=("nerv512u-devkit", "via-devkit")) + with pytest.raises(gw.GatewareProfileError) as exc: + gw.resolve_gateware_project(str(tmp_path), None, {}) + message = str(exc.value) + assert "--profile" in message + # The error has to name the candidates, or the user has to go look. + assert "via-devkit" in message and "nerv512u-devkit" in message + + +def test_unknown_profile_raises_and_lists_available(gw, tmp_path): + _make_repo(tmp_path, profiles=("nerv512u-devkit", "via-devkit")) + with pytest.raises(gw.GatewareProfileError) as exc: + gw.resolve_gateware_project(str(tmp_path), "sciop-devkit", {}) + message = str(exc.value) + assert "sciop-devkit" in message + assert "via-devkit" in message and "nerv512u-devkit" in message + + +def test_no_gateware_project_at_all_raises(gw, tmp_path): + with pytest.raises(gw.GatewareProfileError) as exc: + gw.resolve_gateware_project(str(tmp_path), None, {}) + assert "No gateware project found" in str(exc.value) + + +def test_bare_gateware_dir_without_manifest_raises(gw, tmp_path): + """An empty src/gateware/ is not a project. (The pass-through treats this + as the scaffolding case and redirects anyway; resolution itself still + reports that there is nothing to build.)""" + (tmp_path / "src" / "gateware").mkdir(parents=True) + with pytest.raises(gw.GatewareProfileError): + gw.resolve_gateware_project(str(tmp_path), None, {}) + + +# --------------------------------------------------------------------------- +# run_gateware_build honours the resolved project +# --------------------------------------------------------------------------- + + +def test_build_command_and_bit_glob_follow_the_project_subdir( + gw, tmp_path, monkeypatch +): + """The SDK ``--project`` argument and the bitstream glob must agree; a + mismatch would build one profile and package the other's stale .bit.""" + project = "src/gateware/via-devkit" + bitstreams = tmp_path / project / "build" / "bitstreams" + bitstreams.mkdir(parents=True) + expected_bit = bitstreams / "sdk_via-devkit_gateware_extracted.bit" + expected_bit.write_bytes(b"\x00") + + # A decoy under the *other* profile proves the glob is scoped. + other = tmp_path / "src/gateware/nerv512u-devkit/build/bitstreams" + other.mkdir(parents=True) + (other / "sdk_nerv512u-devkit_gateware_extracted.bit").write_bytes(b"\x00") + + recorded = {} + + def fake_run(argv, **kwargs): + recorded["argv"] = argv + import subprocess as sp + + return sp.CompletedProcess(argv, 0) + + monkeypatch.setattr(gw.subprocess, "run", fake_run) + + got = gw.run_gateware_build( + str(tmp_path), + "fake-gw:latest", + env={"LM_LICENSE_FILE": "7788@licenseserver"}, + project_subdir=project, + ) + + assert got == str(expected_bit) + assert f"axon-peripheral-sdk build --project {project}" in recorded["argv"][-1]