Skip to main content

babble_bridge/
xtask.rs

1//! Xtask commands (docker, zephyr-setup, run-bsim) and programmatic API.
2//!
3//! ## CLI usage
4//!
5//! Downstream crates re-export [`cli_main`] via a thin `xtask` binary so
6//! that `cargo xtask <command>` works from their workspace root.
7//!
8//! ## Library / build-script usage
9//!
10//! The heavy-lifting functions are also exposed as a public API so that
11//! another crate's `build.rs` (or any Rust code) can call them directly
12//! without shelling out:
13//!
14//! ```no_run
15//! use std::path::Path;
16//! use babble_bridge::xtask;
17//!
18//! let root = Path::new("/path/to/workspace");
19//! let external = root.join("external");
20//! xtask::fetch_prebuilt_binaries(&root, &external)
21//!     .expect("failed to fetch BabbleSim binaries");
22//! ```
23
24use std::env;
25use std::fs;
26use std::io::{self, BufRead, Write};
27use std::os::unix::fs::PermissionsExt;
28use std::os::unix::net::UnixStream;
29use std::path::{Path, PathBuf};
30use std::process::{Child, Command, Stdio};
31use std::time::{Duration, Instant};
32
33/// Boxed error type used by all public functions in this module.
34pub type DynError = Box<dyn std::error::Error>;
35/// Result alias used by all public functions in this module.
36pub type Result<T> = std::result::Result<T, DynError>;
37
38const DOCKER_IMAGE_TAG: &str = "babble-bridge:latest";
39
40/// How BabbleSim/Zephyr binaries should be installed.
41#[derive(Debug, Clone, Copy, PartialEq, Eq)]
42pub enum InstallMode {
43    /// Build everything from source (~30 min, requires full Zephyr toolchain).
44    BuildFromSource,
45    /// Download a prebuilt release archive from GitHub (~30 s).
46    FetchPrebuilt,
47}
48
49/// Top-level entry point. Call this from your `xtask` binary's `main()`.
50///
51/// Parses `std::env::args()`, dispatches to the matching subcommand, and
52/// calls `std::process::exit(1)` on failure.
53pub fn cli_main() {
54    if let Err(err) = run() {
55        eprintln!("Error: {err}");
56        std::process::exit(1);
57    }
58}
59
60fn run() -> Result<()> {
61    let mut args = env::args().skip(1);
62    let Some(cmd) = args.next() else {
63        print_usage();
64        return Ok(());
65    };
66
67    match cmd.as_str() {
68        "zephyr-setup" => {
69            require_linux("zephyr-setup")?;
70            let args: Vec<String> = args.collect();
71            let clean = args.iter().any(|a| a == "--clean");
72            let mode = if args.iter().any(|a| a == "--prebuilt") {
73                InstallMode::FetchPrebuilt
74            } else if args.iter().any(|a| a == "--build-from-source") {
75                InstallMode::BuildFromSource
76            } else {
77                prompt_install_mode()?
78            };
79            let root = workspace_root()?;
80            zephyr_setup(&root, clean, mode)
81        }
82        "run-bsim" => {
83            require_linux("run-bsim")?;
84            let args: Vec<String> = args.collect();
85            let nrf_rpc_server = args.iter().any(|a| a == "--nrf-rpc-server");
86            let cgm_peripheral = args.iter().any(|a| a == "--cgm-peripheral");
87            run_bsim(nrf_rpc_server, cgm_peripheral)
88        }
89        "start-sim" => {
90            let args: Vec<String> = args.collect();
91            let sim_id = parse_sim_flag(&args, "--sim-id").unwrap_or("sim");
92            let use_docker = args.iter().any(|a| a == "--container");
93            if use_docker {
94                cmd_start_sim_in_docker(sim_id)
95            } else {
96                require_linux("start-sim")?;
97                let root = workspace_root()?;
98                let sim_dir = parse_sim_flag(&args, "--sim-dir")
99                    .map(PathBuf::from)
100                    .unwrap_or_else(|| root.join("tests/sockets"));
101                cmd_start_sim(sim_id, &sim_dir)
102            }
103        }
104        "stop-sim" => {
105            require_linux("stop-sim")?;
106            let args: Vec<String> = args.collect();
107            let sim_id = parse_sim_flag(&args, "--sim-id").unwrap_or("insulin_pump");
108            cmd_stop_sim(sim_id)
109        }
110        "clean-sockets" => {
111            let root = workspace_root()?;
112            cmd_clean_sockets(&root)
113        }
114        "exec" => {
115            let rest: Vec<String> = args.collect();
116            let cmd_args: Vec<&str> = rest
117                .iter()
118                .skip_while(|a| a.as_str() == "--")
119                .map(String::as_str)
120                .collect();
121            if cmd_args.is_empty() {
122                return Err("exec requires a command to run inside the container".into());
123            }
124            cmd_exec_in_container(&cmd_args)
125        }
126        "docker-build" => docker_build(),
127        "docker-attach" => docker_attach(),
128        "docker-run" => {
129            let rest: Vec<String> = args.collect();
130            // Allow an optional `--` separator before the command.
131            let cmd_args: Vec<&str> = rest
132                .iter()
133                .skip_while(|a| a.as_str() == "--")
134                .map(String::as_str)
135                .collect();
136            if cmd_args.is_empty() {
137                return Err("docker-run requires a command to run inside the container".into());
138            }
139            docker_run(&cmd_args)
140        }
141        "-h" | "--help" | "help" => {
142            print_usage();
143            Ok(())
144        }
145        _ => Err(format!("Unknown command: {cmd}").into()),
146    }
147}
148
149fn print_usage() {
150    println!("Usage: cargo xtask <command> [options]");
151    println!();
152    println!("Commands:");
153    println!("  docker-build                      Build the dev-container image");
154    println!("  docker-attach                     Open an interactive shell in the container");
155    println!("  docker-run [--] <cmd> [args...]   Run a command non-interactively in the container (for CI)");
156    println!();
157    println!("  zephyr-setup [--clean]            Set up Zephyr/BabbleSim (prompts for install mode)");
158    println!("    --prebuilt                      Fetch prebuilt binaries from GitHub Releases");
159    println!("    --build-from-source             Build from source (non-interactive, for CI)");
160    println!();
161    println!("  run-bsim                          Run BabbleSim simulation (Linux only)");
162    println!("    --nrf-rpc-server                Launch the nRF RPC server (default: on)");
163    println!("    --cgm-peripheral                Launch the CGM peripheral sample (default: on)");
164    println!();
165    println!("  start-sim                         Start simulation stack in the background (Linux only)");
166    println!("    --sim-id <id>                   Simulation identifier (default: sim)");
167    println!("    --sim-dir <path>                Directory for the socket file (default: <workspace>/tests/sockets)");
168    println!("    --container                     Build image if needed and run inside a container (macOS)");
169    println!("    Prints the socket path on success.");
170    println!();
171    println!("  stop-sim                          Stop a running simulation (Linux only)");
172    println!("    --sim-id <id>                   Simulation identifier to stop (default: insulin_pump)");
173    println!();
174    println!("  clean-sockets                     Remove all *.sock files from <workspace>/tests/sockets/");
175    println!();
176    println!("  exec [--] <cmd> [args...]          Run a command inside the sim container (where the socket is reachable)");
177}
178
179fn require_linux(cmd: &str) -> Result<()> {
180    if !cfg!(target_os = "linux") {
181        return Err(format!(
182            "`xtask {cmd}` requires Linux. \
183             Use `cargo xtask docker-build` to build the dev-container image, \
184             then work inside it."
185        )
186        .into());
187    }
188    Ok(())
189}
190
191// ── Install-mode prompt ──────────────────────────────────────────────────────
192
193fn prompt_install_mode() -> Result<InstallMode> {
194    println!();
195    println!("How would you like to set up the Zephyr/BabbleSim environment?");
196    println!("  [1] Build from source   (slow, ~30 min; requires a full Zephyr toolchain)");
197    println!("  [2] Fetch prebuilt binaries  (fast; downloads a release archive from GitHub)");
198    print!("Enter choice [1/2] (default: 2): ");
199    io::stdout().flush()?;
200
201    let line = io::stdin()
202        .lock()
203        .lines()
204        .next()
205        .ok_or("No input received — stdin was empty")??;
206
207    match line.trim() {
208        "1" => {
209            println!("Selected: build from source.");
210            Ok(InstallMode::BuildFromSource)
211        }
212        "2" | "" => {
213            println!("Selected: fetch prebuilt binaries.");
214            Ok(InstallMode::FetchPrebuilt)
215        }
216        other => Err(format!("Invalid choice '{other}'. Please enter 1 or 2.").into()),
217    }
218}
219
220// ── Docker helpers ──────────────────────────────────────────────────────────
221
222fn docker_build() -> Result<()> {
223    let root = workspace_root()?;
224    let dockerfile = root.join(".devcontainer/Dockerfile");
225    if !dockerfile.exists() {
226        return Err(format!(
227            "Dockerfile not found at {}",
228            dockerfile.display()
229        )
230        .into());
231    }
232
233    let uid = std::env::var("UID").unwrap_or_else(|_| "1000".into());
234    let gid = std::env::var("GID").unwrap_or_else(|_| "1000".into());
235
236    println!("Building Docker image {DOCKER_IMAGE_TAG} …");
237    run_cmd(
238        "docker",
239        &[
240            "build",
241            "--platform", "linux/amd64",
242            "-f", ".devcontainer/Dockerfile",
243            "--build-arg", &format!("USER_UID={uid}"),
244            "--build-arg", &format!("USER_GID={gid}"),
245            "-t", DOCKER_IMAGE_TAG,
246            ".",
247        ],
248        Some(&root),
249    )?;
250    println!("Image built: {DOCKER_IMAGE_TAG}");
251    Ok(())
252}
253
254fn docker_attach() -> Result<()> {
255    let root = workspace_root()?;
256    let workspace = root
257        .to_str()
258        .ok_or("Workspace path contains non-UTF-8 characters")?;
259
260    run_cmd(
261        "docker",
262        &[
263            "run",
264            "--rm",
265            "--interactive",
266            "--tty",
267            "--platform", "linux/amd64",
268            "-v", &format!("{workspace}:/workspace"),
269            "-w", "/workspace",
270            DOCKER_IMAGE_TAG,
271            "bash",
272        ],
273        Some(&root),
274    )
275}
276
277/// Run a command non-interactively inside a one-shot container.
278///
279/// Unlike `docker-attach`, this does not allocate a TTY, so it works in CI
280/// environments where stdin is not a terminal. The workspace is bind-mounted
281/// at `/workspace`, so state written by the command (e.g. `external/` setup
282/// output) persists on the host across invocations.
283fn docker_run(cmd_args: &[&str]) -> Result<()> {
284    let root = workspace_root()?;
285    let workspace = root
286        .to_str()
287        .ok_or("Workspace path contains non-UTF-8 characters")?;
288
289    let mount = format!("{workspace}:/workspace");
290    let mut docker_args: Vec<&str> = vec![
291        "run",
292        "--rm",
293        "--platform", "linux/amd64",
294        "-v", &mount,
295        "-w", "/workspace",
296        DOCKER_IMAGE_TAG,
297    ];
298    docker_args.extend_from_slice(cmd_args);
299
300    run_cmd("docker", &docker_args, Some(&root))
301}
302
303// ── Workspace / utility helpers ──────────────────────────────────────────────
304
305/// Walk up from the current directory until a `Cargo.toml` is found.
306///
307/// This is the same heuristic the CLI uses; exposed publicly so that
308/// downstream `build.rs` scripts can locate the workspace root without
309/// duplicating the logic.
310pub fn workspace_root() -> Result<PathBuf> {
311    let mut dir = env::current_dir()?;
312    loop {
313        if dir.join("Cargo.toml").exists() {
314            return Ok(dir);
315        }
316        if !dir.pop() {
317            return Err("Could not find workspace root (no Cargo.toml found in any parent directory)".into());
318        }
319    }
320}
321
322fn run_cmd(cmd: &str, args: &[&str], cwd: Option<&Path>) -> Result<()> {
323    let mut command = Command::new(cmd);
324    command.args(args);
325    if let Some(cwd) = cwd {
326        command.current_dir(cwd);
327    }
328    command.stdin(Stdio::inherit());
329    command.stdout(Stdio::inherit());
330    command.stderr(Stdio::inherit());
331
332    let status = command.status()?;
333    if !status.success() {
334        return Err(format!("Command failed: {cmd} {}", args.join(" ")).into());
335    }
336    Ok(())
337}
338
339/// Create `<root>/tests/sockets/` with restricted permissions (0o700).
340///
341/// Refuses to follow an existing symlink at that path to prevent symlink
342/// substitution attacks. Only called when `DEVCONTAINER=1` is set.
343fn create_sockets_dir(root: &Path) -> Result<()> {
344    let sockets_dir = root.join("tests/sockets");
345
346    // Guard against a symlink planted before we run.
347    if sockets_dir.exists() && sockets_dir.symlink_metadata()?.file_type().is_symlink() {
348        return Err(format!(
349            "Refusing to use '{}': it is a symlink. \
350             Remove it manually before running zephyr-setup.",
351            sockets_dir.display()
352        )
353        .into());
354    }
355
356    if !sockets_dir.exists() {
357        fs::create_dir_all(&sockets_dir)?;
358        println!("Created {}", sockets_dir.display());
359    }
360
361    // Restrict to owner only so no other local user can connect to sockets here.
362    fs::set_permissions(&sockets_dir, fs::Permissions::from_mode(0o700))?;
363    Ok(())
364}
365
366fn clean_dir(dir: &Path) -> Result<()> {
367    if !dir.exists() {
368        return Ok(());
369    }
370    for entry in fs::read_dir(dir)? {
371        let entry = entry?;
372        if entry.file_name() == ".gitignore" {
373            continue;
374        }
375        let path = entry.path();
376        if path.is_dir() {
377            fs::remove_dir_all(path)?;
378        } else {
379            fs::remove_file(path)?;
380        }
381    }
382    Ok(())
383}
384
385/// Hardcoded "latest release" URL for the prebuilt BabbleSim bundle.
386///
387/// The asset filenames are FIXED (no SHA in the name), so this URL pattern
388/// resolves to the most recently published release without any GitHub API
389/// calls or authentication. See `.github/workflows/publish.yml`.
390const PREBUILT_RELEASE_URL_BASE: &str =
391    "https://github.com/tyler-potyondy/nrf-sim-bridge/releases/latest/download";
392const PREBUILT_TARBALL_NAME: &str = "bsim-prebuilt.tar.gz";
393const PREBUILT_SHA256_NAME: &str = "bsim-prebuilt.tar.gz.sha256";
394
395/// Download the latest published prebuilt BabbleSim bundle, verify its
396/// SHA-256, and extract it into `<external_dir>/tools/bsim/`.
397///
398/// The tarball produced by the publish workflow contains `bin/`, `lib/`, and
399/// `components/` at its root, so extracting into `external/tools/bsim/`
400/// reproduces the exact layout that [`zephyr_setup`] with
401/// [`InstallMode::BuildFromSource`] would create — without spending ~30
402/// minutes rebuilding Zephyr/BabbleSim from source.
403///
404/// `root` is the workspace root (currently unused but reserved for future
405/// path resolution).  `external_dir` is typically `root.join("external")`.
406///
407/// Requires `curl`, `sha256sum`, and `tar` on `PATH` (all present in the
408/// devcontainer).
409pub fn fetch_prebuilt_binaries(root: &Path, external_dir: &Path) -> Result<()> {
410    let _ = root;
411    let bsim_dir = external_dir.join("tools/bsim");
412    fs::create_dir_all(&bsim_dir)?;
413
414    let download_dir = external_dir.join(".prebuilt-download");
415    if download_dir.exists() {
416        fs::remove_dir_all(&download_dir)?;
417    }
418    fs::create_dir_all(&download_dir)?;
419
420    let tarball = download_dir.join(PREBUILT_TARBALL_NAME);
421    let sha_file = download_dir.join(PREBUILT_SHA256_NAME);
422    let tarball_url = format!("{PREBUILT_RELEASE_URL_BASE}/{PREBUILT_TARBALL_NAME}");
423    let sha_url = format!("{PREBUILT_RELEASE_URL_BASE}/{PREBUILT_SHA256_NAME}");
424
425    let tarball_str = tarball.to_str().ok_or("Invalid UTF-8 path for tarball")?;
426    let sha_file_str = sha_file.to_str().ok_or("Invalid UTF-8 path for sha file")?;
427    let bsim_dir_str = bsim_dir.to_str().ok_or("Invalid UTF-8 path for bsim dir")?;
428
429    println!("Downloading {tarball_url} ...");
430    run_cmd(
431        "curl",
432        &["--fail", "--location", "--show-error", "--silent",
433          "--output", tarball_str, &tarball_url],
434        None,
435    )?;
436
437    println!("Downloading {sha_url} ...");
438    run_cmd(
439        "curl",
440        &["--fail", "--location", "--show-error", "--silent",
441          "--output", sha_file_str, &sha_url],
442        None,
443    )?;
444
445    println!("Verifying SHA-256 ...");
446    // `sha256sum -c` matches filenames as written in the .sha256 file
447    // (relative to cwd), so run it from the directory containing both files.
448    run_cmd(
449        "sha256sum",
450        &["--check", "--strict", PREBUILT_SHA256_NAME],
451        Some(&download_dir),
452    )?;
453
454    // Wipe any stale bin/lib/components from a previous setup so we don't
455    // mix files from a partially-extracted older bundle.
456    for sub in ["bin", "lib", "components"] {
457        let p = bsim_dir.join(sub);
458        if p.exists() {
459            fs::remove_dir_all(&p)?;
460        }
461    }
462
463    println!("Extracting into {} ...", bsim_dir.display());
464    run_cmd(
465        "tar",
466        &["-xzf", tarball_str, "-C", bsim_dir_str],
467        None,
468    )?;
469
470    fs::remove_dir_all(&download_dir)?;
471
472    println!("Prebuilt binaries installed to {}", bsim_dir.display());
473    println!("  bin/        — BabbleSim + Zephyr app binaries");
474    println!("  lib/        — shared libraries (LD_LIBRARY_PATH)");
475    println!("  components/ — BabbleSim runtime components");
476    Ok(())
477}
478
479// ── Zephyr setup ─────────────────────────────────────────────────────────────
480
481/// Set up the Zephyr / BabbleSim environment under `root`.
482///
483/// When `clean` is true, everything under `<root>/external/` (except
484/// `.gitignore`) is removed first.  `mode` selects between a full
485/// source build and a fast prebuilt-binary download.
486///
487/// # Example
488///
489/// ```no_run
490/// use std::path::Path;
491/// use babble_bridge::xtask::{self, InstallMode};
492///
493/// let root = xtask::workspace_root().unwrap();
494/// xtask::zephyr_setup(&root, false, InstallMode::FetchPrebuilt).unwrap();
495/// ```
496pub fn zephyr_setup(root: &Path, clean: bool, mode: InstallMode) -> Result<()> {
497    let external_dir = root.join("external");
498
499    if clean {
500        println!("Cleaning up {}...", external_dir.display());
501        clean_dir(&external_dir)?;
502    }
503
504    fs::create_dir_all(&external_dir)?;
505    create_sockets_dir(root)?;
506
507    if let InstallMode::FetchPrebuilt = mode {
508        return fetch_prebuilt_binaries(root, &external_dir);
509    }
510
511    // Initialize the nrf submodule. This is idempotent: a no-op if
512    // already initialized, otherwise clones it. In CI, `actions/checkout`
513    // with `submodules: recursive` already populates it, in which case
514    // this call just verifies the SHA matches.
515    println!("Setting up nrf submodule...");
516    run_cmd(
517        "git",
518        &["submodule", "update", "--init", "external/nrf"],
519        Some(root),
520    )?;
521
522    let venv_dir = external_dir.join(".venv");
523    let venv_python = venv_dir.join("bin/python3");
524    let venv_stamp = venv_dir.join(".requirements_installed");
525
526    let python_ok = venv_python.exists()
527        && Command::new(&venv_python)
528            .arg("--version")
529            .stdout(Stdio::null())
530            .stderr(Stdio::null())
531            .status()
532            .map(|s| s.success())
533            .unwrap_or(false);
534    let venv_valid = python_ok && venv_stamp.exists();
535
536    if !venv_valid {
537        if venv_dir.exists() {
538            if !python_ok {
539                println!("Existing venv is stale or from a different Python, recreating...");
540            } else {
541                println!("Existing venv has incomplete requirements, recreating...");
542            }
543            fs::remove_dir_all(&venv_dir)?;
544        } else {
545            println!("Creating venv...");
546        }
547        run_cmd("python3", &["-m", "venv", ".venv"], Some(&external_dir))?;
548    }
549
550    let pip = external_dir.join(".venv/bin/pip");
551    let west = external_dir.join(".venv/bin/west");
552    let pip_str = pip.to_str().ok_or("Invalid UTF-8 path for pip")?;
553    let west_str = west.to_str().ok_or("Invalid UTF-8 path for west")?;
554
555    let venv_bin = venv_dir.join("bin");
556    let venv_bin_str = venv_bin.to_str().ok_or("Invalid UTF-8 path for venv bin")?;
557    let path = env::var("PATH").unwrap_or_default();
558    std::env::set_var("PATH", format!("{venv_bin_str}:{path}"));
559    std::env::set_var("VIRTUAL_ENV", &venv_dir);
560
561    run_cmd(pip_str, &["install", "west"], Some(&external_dir))?;
562
563    let west_state = external_dir.join(".west");
564    if west_state.exists() {
565        println!("Previous west workspace found, resetting...");
566        fs::remove_dir_all(west_state)?;
567    }
568
569    run_cmd(west_str, &["init", "-l", "nrf"], Some(&external_dir))?;
570    println!("Fetching west dependencies (BabbleSim + Zephyr)...");
571    run_cmd(
572        west_str,
573        &["config", "manifest.group-filter", "--", "+babblesim"],
574        Some(&external_dir),
575    )?;
576    run_cmd(west_str, &["update"], Some(&external_dir))?;
577
578    run_cmd(
579        pip_str,
580        &["install", "-r", "nrf/scripts/requirements.txt"],
581        Some(&external_dir),
582    )?;
583    run_cmd(
584        pip_str,
585        &["install", "-r", "zephyr/scripts/requirements.txt"],
586        Some(&external_dir),
587    )?;
588
589    println!("Verifying all requirements are installed...");
590    let dry_run = Command::new(pip_str)
591        .args([
592            "install",
593            "-r", "nrf/scripts/requirements.txt",
594            "-r", "zephyr/scripts/requirements.txt",
595            "--dry-run",
596        ])
597        .current_dir(&external_dir)
598        .stdout(Stdio::piped())
599        .stderr(Stdio::piped())
600        .output()?;
601
602    let dry_run_out = String::from_utf8_lossy(&dry_run.stdout);
603    let dry_run_err = String::from_utf8_lossy(&dry_run.stderr);
604    let combined = format!("{dry_run_out}{dry_run_err}");
605    if combined.contains("Would install") {
606        return Err(format!(
607            "Requirements are not fully installed after pip install — \
608             the following packages are still missing or out of range:\n{combined}\n\
609             Re-run with --clean to start fresh."
610        )
611        .into());
612    }
613
614    fs::write(&venv_stamp, "")?;
615
616    println!("Building BabbleSim...");
617    run_cmd(
618        "make",
619        &["-C", "tools/bsim", "everything", "-j", "4"],
620        Some(&external_dir),
621    )?;
622
623    println!("Checking out cgm-bsim branch in nrf...");
624    let current_branch = Command::new("git")
625        .args(["-C", "nrf", "rev-parse", "--abbrev-ref", "HEAD"])
626        .current_dir(&external_dir)
627        .output()?;
628    if !current_branch.status.success() {
629        return Err("Failed to read current nrf branch".into());
630    }
631    if String::from_utf8(current_branch.stdout)?.trim() != "cgm-bsim" {
632        run_cmd(
633            "git",
634            &["-C", "nrf", "fetch", "origin", "cgm-bsim"],
635            Some(&external_dir),
636        )?;
637        run_cmd(
638            "git",
639            &["-C", "nrf", "checkout", "-B", "cgm-bsim", "FETCH_HEAD"],
640            Some(&external_dir),
641        )?;
642    }
643
644    println!("Building Zephyr server app...");
645    run_cmd(
646        west_str,
647        &[
648            "build", "-b", "nrf52_bsim", "-p", "always",
649            "--build-dir", "build/zephyr_server_app",
650            "nrf/samples/nrf_rpc/protocols_serialization/server",
651            "-S", "ble",
652        ],
653        Some(&external_dir),
654    )?;
655
656    println!("Building CGM peripheral sample...");
657    run_cmd(
658        west_str,
659        &[
660            "build", "-b", "nrf52_bsim", "-p", "always",
661            "--build-dir", "build/cgm_peripheral_sample",
662            "nrf/samples/bluetooth/peripheral_cgms",
663        ],
664        Some(&external_dir),
665    )?;
666
667    fs::copy(
668        external_dir.join("build/zephyr_server_app/server/zephyr/zephyr.exe"),
669        external_dir.join("tools/bsim/bin/zephyr_rpc_server_app"),
670    )?;
671    fs::copy(
672        external_dir.join("build/cgm_peripheral_sample/peripheral_cgms/zephyr/zephyr.exe"),
673        external_dir.join("tools/bsim/bin/cgm_peripheral_sample"),
674    )?;
675
676    println!("Done. Build artifacts copied to external/tools/bsim/bin/");
677    Ok(())
678}
679
680// ── Sim-management subcommands ────────────────────────────────────────────────
681
682/// Return the value that follows `flag` in `args`, if present.
683fn parse_sim_flag<'a>(args: &'a [String], flag: &str) -> Option<&'a str> {
684    args.windows(2)
685        .find(|w| w[0] == flag)
686        .map(|w| w[1].as_str())
687}
688
689/// `cargo xtask start-sim` — launch the full simulation stack in the background.
690///
691/// Spawns PHY + `zephyr_rpc_server_app` + `cgm_peripheral_sample` + `socat`,
692/// waits until the UNIX socket at `<sim_dir>/<sim_id>.sock` is connectable,
693/// then prints the socket path and exits, leaving all child processes running.
694fn cmd_start_sim(sim_id: &str, sim_dir: &Path) -> Result<()> {
695    // Verify required binaries exist before attempting to spawn anything.
696    let bsim_bin = Path::new("external/tools/bsim/bin");
697    let required = ["bs_2G4_phy_v1", "zephyr_rpc_server_app", "cgm_peripheral_sample"];
698    let missing: Vec<&str> = required
699        .iter()
700        .copied()
701        .filter(|name| !bsim_bin.join(name).is_file())
702        .collect();
703    if !missing.is_empty() {
704        return Err(format!(
705            "Missing required binaries in {}:\n{}\n\
706             Run `cargo xtask zephyr-setup` to install them.",
707            bsim_bin.display(),
708            missing.iter().map(|n| format!("  - {n}")).collect::<Vec<_>>().join("\n"),
709        )
710        .into());
711    }
712
713    let (processes, socket_path) =
714        crate::spawn_zephyr_rpc_server_with_socat(sim_dir, sim_id);
715
716    // Wait until socat is actually listening on the socket before we exit.
717    // socat needs a moment after spawning before it accepts connections.
718    let deadline = Instant::now() + Duration::from_secs(15);
719    loop {
720        if UnixStream::connect(&socket_path).is_ok() {
721            break;
722        }
723        if Instant::now() >= deadline {
724            return Err(format!(
725                "timed out waiting for socket {} to become connectable",
726                socket_path.display()
727            )
728            .into());
729        }
730        std::thread::sleep(Duration::from_millis(100));
731    }
732
733    // IMPORTANT: prevent TestProcesses::drop() from calling kill_all().
734    // Without this the Drop impl would kill every child process the instant
735    // cmd_start_sim returns, tearing down the sim before the caller can use it.
736    std::mem::forget(processes);
737
738    println!("{}", socket_path.display());
739    Ok(())
740}
741
742/// Derive a stable TCP port in the private range (49152–65535) from the
743/// workspace path. Two different repos will get different ports, avoiding
744/// conflicts when both are running simultaneously.
745fn container_port(workspace: &str) -> u16 {
746    let hash = workspace
747        .bytes()
748        .fold(0u64, |h, b| h.wrapping_mul(31).wrapping_add(b as u64));
749    49152 + (hash % (65535 - 49152)) as u16
750}
751
752/// `cargo xtask start-sim --container` — ensure the dev container is running,
753/// then exec `start-sim` inside it so Linux-only sim processes stay alive after
754/// this command returns. The workspace bind-mount means the socket file appears
755/// in `tests/sockets/` on the host as well.
756fn cmd_start_sim_in_docker(sim_id: &str) -> Result<()> {
757    let root = workspace_root()?;
758    let workspace = root
759        .to_str()
760        .ok_or("Workspace path contains non-UTF-8 characters")?;
761
762    // Build the image if it doesn't already exist.
763    let image_exists = Command::new("docker")
764        .args(["image", "inspect", "--format", ".", DOCKER_IMAGE_TAG])
765        .stdout(Stdio::null())
766        .stderr(Stdio::null())
767        .status()
768        .map(|s| s.success())
769        .unwrap_or(false);
770
771    if !image_exists {
772        println!("Docker image {DOCKER_IMAGE_TAG} not found — building...");
773        docker_build()?;
774    }
775
776    // Derive a container name from the workspace path so each repo gets its
777    // own container with the correct workspace bind-mounted.
778    let hash = format!("{:x}", workspace.bytes().fold(0u64, |h, b| h.wrapping_mul(31).wrapping_add(b as u64)));
779    let container_name = format!("babble-bridge-{}", &hash[..8]);
780    let container_name = container_name.as_str();
781    let port = container_port(workspace);
782    let port_mapping = format!("127.0.0.1:{port}:{port}");
783
784    // Check if the container is already running.
785    let container_running = Command::new("docker")
786        .args(["inspect", "--format", "{{.State.Running}}", container_name])
787        .stdout(Stdio::piped())
788        .stderr(Stdio::null())
789        .output()
790        .map(|o| String::from_utf8_lossy(&o.stdout).trim() == "true")
791        .unwrap_or(false);
792
793    if !container_running {
794        // Remove any stopped container with the same name before starting fresh.
795        let _ = Command::new("docker")
796            .args(["rm", "-f", container_name])
797            .stdout(Stdio::null())
798            .stderr(Stdio::null())
799            .status();
800
801        println!("Starting container {container_name} (TCP bridge port {port})...");
802        let mount = format!("{workspace}:/workspace");
803        run_cmd(
804            "docker",
805            &[
806                "run",
807                "--detach",
808                "--tty",            // required: mounts /dev/pts so openpty() works for Zephyr UART PTY
809                "--name", container_name,
810                "--platform", "linux/amd64",
811                "-p", &port_mapping, // TCP bridge: accessible at 127.0.0.1:<port> on macOS
812                "-v", &mount,
813                "-w", "/workspace",
814                DOCKER_IMAGE_TAG,
815                "sleep", "infinity",  // keep the container alive
816            ],
817            Some(&root),
818        )?;
819    } else {
820        println!("Container {container_name} is already running.");
821    }
822
823    println!("Running start-sim inside container...");
824    // First stop any running sim so BabbleSim's IPC resources (shared memory,
825    // semaphores) are fully released before we try to start fresh ones.
826    let _ = run_cmd(
827        "docker",
828        &[
829            "exec",
830            container_name,
831            "bash", "-lc", &format!("cargo xtask stop-sim --sim-id {sim_id}"),
832        ],
833        Some(&root),
834    );
835    // Kill any previous TCP bridge for this sim.
836    let _ = run_cmd(
837        "docker",
838        &[
839            "exec",
840            container_name,
841            "bash", "-lc", &format!("pkill -f 'socat TCP-LISTEN:{port}' || true"),
842        ],
843        Some(&root),
844    );
845    run_cmd(
846        "docker",
847        &[
848            "exec",
849            container_name,
850            "bash", "-lc", &format!("cargo xtask start-sim --sim-id {sim_id}"),
851        ],
852        Some(&root),
853    )?;
854
855    // Launch a socat TCP bridge inside the container so the socket is
856    // reachable from macOS at 127.0.0.1:<port>.
857    let socket_path = format!("/workspace/tests/sockets/{sim_id}.sock");
858    run_cmd(
859        "docker",
860        &[
861            "exec",
862            "--detach",
863            container_name,
864            "bash", "-lc",
865            &format!("socat TCP-LISTEN:{port},reuseaddr,fork UNIX-CLIENT:{socket_path}"),
866        ],
867        Some(&root),
868    )?;
869
870    println!("TCP bridge ready: connect from macOS at 127.0.0.1:{port}");
871    Ok(())
872}
873
874/// `cargo xtask exec` — run an arbitrary command inside the existing sim
875/// container where the Unix socket is reachable.
876///
877/// Unlike `docker-run` (which spins up a fresh container), this execs into
878/// the persistent `babble-bridge-<hash>` container that `start-sim --container`
879/// created, so it shares the same network namespace and the socket created by
880/// `start-sim` is connectable.
881fn cmd_exec_in_container(cmd_args: &[&str]) -> Result<()> {
882    let root = workspace_root()?;
883    let workspace = root
884        .to_str()
885        .ok_or("Workspace path contains non-UTF-8 characters")?;
886    let hash = format!("{:x}", workspace.bytes().fold(0u64, |h, b| h.wrapping_mul(31).wrapping_add(b as u64)));
887    let container_name = format!("babble-bridge-{}", &hash[..8]);
888
889    let container_running = Command::new("docker")
890        .args(["inspect", "--format", "{{.State.Running}}", &container_name])
891        .stdout(Stdio::piped())
892        .stderr(Stdio::null())
893        .output()
894        .map(|o| String::from_utf8_lossy(&o.stdout).trim() == "true")
895        .unwrap_or(false);
896
897    if !container_running {
898        return Err(format!(
899            "Container {container_name} is not running. \
900             Start it first with `cargo xtask start-sim --container`."
901        ).into());
902    }
903
904    let shell_cmd = cmd_args.join(" ");
905    run_cmd(
906        "docker",
907        &["exec", &container_name, "bash", "-lc", &shell_cmd],
908        Some(&root),
909    )
910}
911
912/// `cargo xtask stop-sim` — kill all processes belonging to a running simulation.
913fn cmd_stop_sim(sim_id: &str) -> Result<()> {
914    crate::kill_stale_sim_processes(sim_id);
915    println!("Stopped simulation '{sim_id}'");
916    Ok(())
917}
918
919/// `cargo xtask clean-sockets` — remove all `*.sock` files from `tests/sockets/`.
920fn cmd_clean_sockets(root: &Path) -> Result<()> {
921    let sockets_dir = root.join("tests/sockets");
922    if !sockets_dir.exists() {
923        create_sockets_dir(root)?;
924        println!("No socket files found in {}.", sockets_dir.display());
925        return Ok(());
926    }
927
928    let mut removed = 0usize;
929    for entry in fs::read_dir(&sockets_dir)? {
930        let entry = entry?;
931        let path = entry.path();
932        if path.extension().and_then(|e| e.to_str()) == Some("sock") {
933            fs::remove_file(&path)?;
934            println!("Removed {}", path.display());
935            removed += 1;
936        }
937    }
938
939    if removed == 0 {
940        println!("No socket files found in {}.", sockets_dir.display());
941    } else {
942        println!("Removed {removed} socket file(s).");
943    }
944    Ok(())
945}
946
947// ── BabbleSim runner ─────────────────────────────────────────────────────────
948
949fn bsim_ld_library_path() -> String {
950    match env::var("LD_LIBRARY_PATH") {
951        Ok(existing) => format!("external/tools/bsim/lib:{existing}"),
952        Err(_) => "external/tools/bsim/lib".to_string(),
953    }
954}
955
956fn spawn_in_bsim_bin(sim_id: &str, exe: &str, args: &[&str]) -> Result<Child> {
957    let bsim_bin = Path::new("external/tools/bsim/bin");
958    Command::new(exe)
959        .args(args)
960        .current_dir(bsim_bin)
961        .stdin(Stdio::inherit())
962        .stdout(Stdio::inherit())
963        .stderr(Stdio::inherit())
964        .env("BSIM_OUT_PATH", "external/tools/bsim")
965        .env("BSIM_COMPONENTS_PATH", "external/tools/bsim/components")
966        .env("LD_LIBRARY_PATH", bsim_ld_library_path())
967        .spawn()
968        .map_err(|e| format!("Failed to spawn '{exe}' for sim '{sim_id}': {e}").into())
969}
970
971fn pkill_sim(sim_id: &str) {
972    for process in ["bs_2G4_phy_v1", "zephyr_rpc_server_app", "cgm_peripheral_sample"] {
973        let pattern = format!("{process} -s={sim_id}");
974        let _ = Command::new("pkill").args(["-f", &pattern]).status();
975        let _ = Command::new("pkill").args(["-9", "-f", &pattern]).status();
976    }
977}
978
979fn generate_sim_id() -> String {
980    use std::time::{SystemTime, UNIX_EPOCH};
981    let nanos = SystemTime::now()
982        .duration_since(UNIX_EPOCH)
983        .unwrap_or_default()
984        .subsec_nanos();
985    let pid = std::process::id();
986    format!("sim_{:08x}", nanos ^ (pid << 16))
987}
988
989fn run_bsim(nrf_rpc_server: bool, cgm_peripheral: bool) -> Result<()> {
990    let (run_nrf, run_cgm) = if !nrf_rpc_server && !cgm_peripheral {
991        (true, true)
992    } else {
993        (nrf_rpc_server, cgm_peripheral)
994    };
995
996    let sim_id = generate_sim_id();
997    pkill_sim(&sim_id);
998
999    let _ = fs::remove_dir_all(format!(
1000        "/tmp/bs_{}/{}",
1001        env::var("USER").unwrap_or_default(),
1002        &sim_id
1003    ));
1004
1005    let device_count = (run_nrf as u32) + (run_cgm as u32);
1006
1007    const SEP: &str = "────────────────────────────────────────────────────────────";
1008
1009    println!("  Starting PHY simulator...");
1010    let _phy = spawn_in_bsim_bin(
1011        &sim_id,
1012        "./bs_2G4_phy_v1",
1013        &[
1014            &format!("-s={sim_id}"),
1015            &format!("-D={device_count}"),
1016            "-sim_length=86400e6",
1017        ],
1018    )?;
1019
1020    let nrf_device_idx: u32 = 0;
1021    let cgm_device_idx: u32 = run_nrf as u32;
1022
1023    let mut nrf_proc = if run_nrf {
1024        println!("  Starting nRF RPC server (device {nrf_device_idx})...");
1025        Some(spawn_in_bsim_bin(
1026            &sim_id,
1027            "./zephyr_rpc_server_app",
1028            &[
1029                &format!("-s={sim_id}"),
1030                &format!("-d={nrf_device_idx}"),
1031                "-uart0_pty",
1032                "-uart_pty_pollT=1000",
1033            ],
1034        )?)
1035    } else {
1036        None
1037    };
1038
1039    let mut cgm_proc = if run_cgm {
1040        println!("  Starting CGM peripheral (device {cgm_device_idx})...");
1041        let cgm_log = fs::File::create("external/tools/bsim/bin/cgm_peripheral_sample.log")?;
1042        Some(
1043            Command::new("./cgm_peripheral_sample")
1044                .args([&format!("-s={sim_id}"), &format!("-d={cgm_device_idx}")])
1045                .current_dir("external/tools/bsim/bin")
1046                .stdin(Stdio::null())
1047                .stdout(cgm_log.try_clone()?)
1048                .stderr(cgm_log)
1049                .env("BSIM_OUT_PATH", "external/tools/bsim")
1050                .env("BSIM_COMPONENTS_PATH", "external/tools/bsim/components")
1051                .env("LD_LIBRARY_PATH", bsim_ld_library_path())
1052                .spawn()?,
1053        )
1054    } else {
1055        None
1056    };
1057
1058    let mut device_list = Vec::new();
1059    if run_nrf { device_list.push(format!("nrf-rpc-server [d={nrf_device_idx}]")); }
1060    if run_cgm { device_list.push(format!("cgm-peripheral [d={cgm_device_idx}]")); }
1061    let device_str = device_list.join(", ");
1062
1063    println!();
1064    println!("{SEP}");
1065    println!("  Simulation ID : {sim_id}");
1066    println!("  Devices       : {device_str}");
1067    println!("  Duration      : 86400 s  (~24 h simulated, ~39 s real time)");
1068    println!("{SEP}");
1069
1070    if run_nrf {
1071        println!();
1072        println!("  To test RX, run in another terminal:");
1073        println!();
1074        println!("    socat UNIX-LISTEN:/tmp/nrf_rpc_server.sock,fork /dev/pts/XX,raw,echo=0");
1075        println!("    printf '\\x04\\x00\\xff\\x00\\xff\\x00\\x62\\x74\\x5f\\x72\\x70\\x63' \\");
1076        println!("      | socat - UNIX-CONNECT:/tmp/nrf_rpc_server.sock");
1077    }
1078
1079    println!();
1080    println!("  Press Ctrl+C to stop.");
1081    println!();
1082
1083    if let Some(ref mut proc) = nrf_proc {
1084        let status = proc.wait()?;
1085        if let Some(ref mut cgm) = cgm_proc {
1086            let _ = cgm.kill();
1087        }
1088        if !status.success() {
1089            return Err(format!("zephyr_rpc_server_app exited with status: {status}").into());
1090        }
1091    } else if let Some(ref mut proc) = cgm_proc {
1092        let status = proc.wait()?;
1093        if !status.success() {
1094            return Err(format!("cgm_peripheral_sample exited with status: {status}").into());
1095        }
1096    }
1097
1098    Ok(())
1099}