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
385fn ensure_external_nrf_checkout(root: &Path, external_dir: &Path) -> Result<()> {
386    let nrf_dir = external_dir.join("nrf");
387    if nrf_dir.exists() {
388        if nrf_dir.is_dir() {
389            println!("Using existing {}", nrf_dir.display());
390            return Ok(());
391        }
392        return Err(format!(
393            "Expected '{}' to be a directory, but it is not. Remove it and re-run setup.",
394            nrf_dir.display()
395        )
396        .into());
397    }
398
399    if gitmodules_declares_external_nrf(root)? {
400        println!("Setting up nrf submodule...");
401        run_cmd(
402            "git",
403            &["submodule", "update", "--init", "external/nrf"],
404            Some(root),
405        )?;
406        return Ok(());
407    }
408
409    let repo = env::var("BABBLE_BRIDGE_NRF_REPO").map_err(|_| {
410        "No external/nrf checkout found and no submodule metadata for external/nrf in .gitmodules. \
411Set BABBLE_BRIDGE_NRF_REPO (and optionally BABBLE_BRIDGE_NRF_REF) so babble-bridge can clone sdk-nrf into external/nrf."
412    })?;
413
414    println!("Cloning nrf into external/nrf from BABBLE_BRIDGE_NRF_REPO...");
415    run_cmd("git", &["clone", &repo, "external/nrf"], Some(root))?;
416
417    if let Ok(nrf_ref) = env::var("BABBLE_BRIDGE_NRF_REF") {
418        if !nrf_ref.trim().is_empty() {
419            println!("Checking out BABBLE_BRIDGE_NRF_REF={nrf_ref}...");
420            run_cmd(
421                "git",
422                &["-C", "external/nrf", "checkout", nrf_ref.trim()],
423                Some(root),
424            )?;
425        }
426    }
427
428    Ok(())
429}
430
431fn gitmodules_declares_external_nrf(root: &Path) -> Result<bool> {
432    let gitmodules = root.join(".gitmodules");
433    if !gitmodules.exists() {
434        return Ok(false);
435    }
436
437    let output = Command::new("git")
438        .args([
439            "config",
440            "-f",
441            ".gitmodules",
442            "--get",
443            "submodule.external/nrf.path",
444        ])
445        .current_dir(root)
446        .output()?;
447
448    if !output.status.success() {
449        return Ok(false);
450    }
451
452    Ok(String::from_utf8(output.stdout)?.trim() == "external/nrf")
453}
454
455/// Hardcoded "latest release" URL for the prebuilt BabbleSim bundle.
456///
457/// The asset filenames are FIXED (no SHA in the name), so this URL pattern
458/// resolves to the most recently published release without any GitHub API
459/// calls or authentication. See `.github/workflows/publish.yml`.
460const PREBUILT_RELEASE_URL_BASE: &str =
461    "https://github.com/tyler-potyondy/nrf-sim-bridge/releases/latest/download";
462const PREBUILT_TARBALL_NAME: &str = "bsim-prebuilt.tar.gz";
463const PREBUILT_SHA256_NAME: &str = "bsim-prebuilt.tar.gz.sha256";
464
465/// Download the latest published prebuilt BabbleSim bundle, verify its
466/// SHA-256, and extract it into `<external_dir>/tools/bsim/`.
467///
468/// The tarball produced by the publish workflow contains `bin/`, `lib/`, and
469/// `components/` at its root, so extracting into `external/tools/bsim/`
470/// reproduces the exact layout that [`zephyr_setup`] with
471/// [`InstallMode::BuildFromSource`] would create — without spending ~30
472/// minutes rebuilding Zephyr/BabbleSim from source.
473///
474/// `root` is the workspace root (currently unused but reserved for future
475/// path resolution).  `external_dir` is typically `root.join("external")`.
476///
477/// Requires `curl`, `sha256sum`, and `tar` on `PATH` (all present in the
478/// devcontainer).
479pub fn fetch_prebuilt_binaries(root: &Path, external_dir: &Path) -> Result<()> {
480    let _ = root;
481    let bsim_dir = external_dir.join("tools/bsim");
482    fs::create_dir_all(&bsim_dir)?;
483
484    let download_dir = external_dir.join(".prebuilt-download");
485    if download_dir.exists() {
486        fs::remove_dir_all(&download_dir)?;
487    }
488    fs::create_dir_all(&download_dir)?;
489
490    let tarball = download_dir.join(PREBUILT_TARBALL_NAME);
491    let sha_file = download_dir.join(PREBUILT_SHA256_NAME);
492    let tarball_url = format!("{PREBUILT_RELEASE_URL_BASE}/{PREBUILT_TARBALL_NAME}");
493    let sha_url = format!("{PREBUILT_RELEASE_URL_BASE}/{PREBUILT_SHA256_NAME}");
494
495    let tarball_str = tarball.to_str().ok_or("Invalid UTF-8 path for tarball")?;
496    let sha_file_str = sha_file.to_str().ok_or("Invalid UTF-8 path for sha file")?;
497    let bsim_dir_str = bsim_dir.to_str().ok_or("Invalid UTF-8 path for bsim dir")?;
498
499    println!("Downloading {tarball_url} ...");
500    run_cmd(
501        "curl",
502        &["--fail", "--location", "--show-error", "--silent",
503          "--output", tarball_str, &tarball_url],
504        None,
505    )?;
506
507    println!("Downloading {sha_url} ...");
508    run_cmd(
509        "curl",
510        &["--fail", "--location", "--show-error", "--silent",
511          "--output", sha_file_str, &sha_url],
512        None,
513    )?;
514
515    println!("Verifying SHA-256 ...");
516    // `sha256sum -c` matches filenames as written in the .sha256 file
517    // (relative to cwd), so run it from the directory containing both files.
518    run_cmd(
519        "sha256sum",
520        &["--check", "--strict", PREBUILT_SHA256_NAME],
521        Some(&download_dir),
522    )?;
523
524    // Wipe any stale bin/lib/components from a previous setup so we don't
525    // mix files from a partially-extracted older bundle.
526    for sub in ["bin", "lib", "components"] {
527        let p = bsim_dir.join(sub);
528        if p.exists() {
529            fs::remove_dir_all(&p)?;
530        }
531    }
532
533    println!("Extracting into {} ...", bsim_dir.display());
534    run_cmd(
535        "tar",
536        &["-xzf", tarball_str, "-C", bsim_dir_str],
537        None,
538    )?;
539
540    fs::remove_dir_all(&download_dir)?;
541
542    println!("Prebuilt binaries installed to {}", bsim_dir.display());
543    println!("  bin/        — BabbleSim + Zephyr app binaries");
544    println!("  lib/        — shared libraries (LD_LIBRARY_PATH)");
545    println!("  components/ — BabbleSim runtime components");
546    Ok(())
547}
548
549// ── Zephyr setup ─────────────────────────────────────────────────────────────
550
551/// Set up the Zephyr / BabbleSim environment under `root`.
552///
553/// When `clean` is true, everything under `<root>/external/` (except
554/// `.gitignore`) is removed first.  `mode` selects between a full
555/// source build and a fast prebuilt-binary download.
556///
557/// # Example
558///
559/// ```no_run
560/// use std::path::Path;
561/// use babble_bridge::xtask::{self, InstallMode};
562///
563/// let root = xtask::workspace_root().unwrap();
564/// xtask::zephyr_setup(&root, false, InstallMode::FetchPrebuilt).unwrap();
565/// ```
566pub fn zephyr_setup(root: &Path, clean: bool, mode: InstallMode) -> Result<()> {
567    let external_dir = root.join("external");
568
569    if clean {
570        println!("Cleaning up {}...", external_dir.display());
571        clean_dir(&external_dir)?;
572    }
573
574    fs::create_dir_all(&external_dir)?;
575    create_sockets_dir(root)?;
576
577    if let InstallMode::FetchPrebuilt = mode {
578        return fetch_prebuilt_binaries(root, &external_dir);
579    }
580
581    ensure_external_nrf_checkout(root, &external_dir)?;
582
583    let venv_dir = external_dir.join(".venv");
584    let venv_python = venv_dir.join("bin/python3");
585    let venv_stamp = venv_dir.join(".requirements_installed");
586
587    let python_ok = venv_python.exists()
588        && Command::new(&venv_python)
589            .arg("--version")
590            .stdout(Stdio::null())
591            .stderr(Stdio::null())
592            .status()
593            .map(|s| s.success())
594            .unwrap_or(false);
595    let venv_valid = python_ok && venv_stamp.exists();
596
597    if !venv_valid {
598        if venv_dir.exists() {
599            if !python_ok {
600                println!("Existing venv is stale or from a different Python, recreating...");
601            } else {
602                println!("Existing venv has incomplete requirements, recreating...");
603            }
604            fs::remove_dir_all(&venv_dir)?;
605        } else {
606            println!("Creating venv...");
607        }
608        run_cmd("python3", &["-m", "venv", ".venv"], Some(&external_dir))?;
609    }
610
611    let pip = external_dir.join(".venv/bin/pip");
612    let west = external_dir.join(".venv/bin/west");
613    let pip_str = pip.to_str().ok_or("Invalid UTF-8 path for pip")?;
614    let west_str = west.to_str().ok_or("Invalid UTF-8 path for west")?;
615
616    let venv_bin = venv_dir.join("bin");
617    let venv_bin_str = venv_bin.to_str().ok_or("Invalid UTF-8 path for venv bin")?;
618    let path = env::var("PATH").unwrap_or_default();
619    std::env::set_var("PATH", format!("{venv_bin_str}:{path}"));
620    std::env::set_var("VIRTUAL_ENV", &venv_dir);
621
622    run_cmd(pip_str, &["install", "west"], Some(&external_dir))?;
623
624    let west_state = external_dir.join(".west");
625    if west_state.exists() {
626        println!("Previous west workspace found, resetting...");
627        fs::remove_dir_all(west_state)?;
628    }
629
630    run_cmd(west_str, &["init", "-l", "nrf"], Some(&external_dir))?;
631    println!("Fetching west dependencies (BabbleSim + Zephyr)...");
632    run_cmd(
633        west_str,
634        &["config", "manifest.group-filter", "--", "+babblesim"],
635        Some(&external_dir),
636    )?;
637    run_cmd(west_str, &["update"], Some(&external_dir))?;
638
639    run_cmd(
640        pip_str,
641        &["install", "-r", "nrf/scripts/requirements.txt"],
642        Some(&external_dir),
643    )?;
644    run_cmd(
645        pip_str,
646        &["install", "-r", "zephyr/scripts/requirements.txt"],
647        Some(&external_dir),
648    )?;
649
650    println!("Verifying all requirements are installed...");
651    let dry_run = Command::new(pip_str)
652        .args([
653            "install",
654            "-r", "nrf/scripts/requirements.txt",
655            "-r", "zephyr/scripts/requirements.txt",
656            "--dry-run",
657        ])
658        .current_dir(&external_dir)
659        .stdout(Stdio::piped())
660        .stderr(Stdio::piped())
661        .output()?;
662
663    let dry_run_out = String::from_utf8_lossy(&dry_run.stdout);
664    let dry_run_err = String::from_utf8_lossy(&dry_run.stderr);
665    let combined = format!("{dry_run_out}{dry_run_err}");
666    if combined.contains("Would install") {
667        return Err(format!(
668            "Requirements are not fully installed after pip install — \
669             the following packages are still missing or out of range:\n{combined}\n\
670             Re-run with --clean to start fresh."
671        )
672        .into());
673    }
674
675    fs::write(&venv_stamp, "")?;
676
677    println!("Building BabbleSim...");
678    run_cmd(
679        "make",
680        &["-C", "tools/bsim", "everything", "-j", "4"],
681        Some(&external_dir),
682    )?;
683
684    println!("Checking out cgm-bsim branch in nrf...");
685    let current_branch = Command::new("git")
686        .args(["-C", "nrf", "rev-parse", "--abbrev-ref", "HEAD"])
687        .current_dir(&external_dir)
688        .output()?;
689    if !current_branch.status.success() {
690        return Err("Failed to read current nrf branch".into());
691    }
692    if String::from_utf8(current_branch.stdout)?.trim() != "cgm-bsim" {
693        run_cmd(
694            "git",
695            &["-C", "nrf", "fetch", "origin", "cgm-bsim"],
696            Some(&external_dir),
697        )?;
698        run_cmd(
699            "git",
700            &["-C", "nrf", "checkout", "-B", "cgm-bsim", "FETCH_HEAD"],
701            Some(&external_dir),
702        )?;
703    }
704
705    println!("Building Zephyr server app...");
706    run_cmd(
707        west_str,
708        &[
709            "build", "-b", "nrf52_bsim", "-p", "always",
710            "--build-dir", "build/zephyr_server_app",
711            "nrf/samples/nrf_rpc/protocols_serialization/server",
712            "-S", "ble",
713        ],
714        Some(&external_dir),
715    )?;
716
717    println!("Building CGM peripheral sample...");
718    run_cmd(
719        west_str,
720        &[
721            "build", "-b", "nrf52_bsim", "-p", "always",
722            "--build-dir", "build/cgm_peripheral_sample",
723            "nrf/samples/bluetooth/peripheral_cgms",
724        ],
725        Some(&external_dir),
726    )?;
727
728    fs::copy(
729        external_dir.join("build/zephyr_server_app/server/zephyr/zephyr.exe"),
730        external_dir.join("tools/bsim/bin/zephyr_rpc_server_app"),
731    )?;
732    fs::copy(
733        external_dir.join("build/cgm_peripheral_sample/peripheral_cgms/zephyr/zephyr.exe"),
734        external_dir.join("tools/bsim/bin/cgm_peripheral_sample"),
735    )?;
736
737    println!("Done. Build artifacts copied to external/tools/bsim/bin/");
738    Ok(())
739}
740
741// ── Sim-management subcommands ────────────────────────────────────────────────
742
743/// Return the value that follows `flag` in `args`, if present.
744fn parse_sim_flag<'a>(args: &'a [String], flag: &str) -> Option<&'a str> {
745    args.windows(2)
746        .find(|w| w[0] == flag)
747        .map(|w| w[1].as_str())
748}
749
750/// `cargo xtask start-sim` — launch the full simulation stack in the background.
751///
752/// Spawns PHY + `zephyr_rpc_server_app` + `cgm_peripheral_sample` + `socat`,
753/// waits until the UNIX socket at `<sim_dir>/<sim_id>.sock` is connectable,
754/// then prints the socket path and exits, leaving all child processes running.
755fn cmd_start_sim(sim_id: &str, sim_dir: &Path) -> Result<()> {
756    // Verify required binaries exist before attempting to spawn anything.
757    let bsim_bin = Path::new("external/tools/bsim/bin");
758    let required = ["bs_2G4_phy_v1", "zephyr_rpc_server_app", "cgm_peripheral_sample"];
759    let missing: Vec<&str> = required
760        .iter()
761        .copied()
762        .filter(|name| !bsim_bin.join(name).is_file())
763        .collect();
764    if !missing.is_empty() {
765        return Err(format!(
766            "Missing required binaries in {}:\n{}\n\
767             Run `cargo xtask zephyr-setup` to install them.",
768            bsim_bin.display(),
769            missing.iter().map(|n| format!("  - {n}")).collect::<Vec<_>>().join("\n"),
770        )
771        .into());
772    }
773
774    let (processes, socket_path) =
775        crate::spawn_zephyr_rpc_server_with_socat(sim_dir, sim_id);
776
777    // Wait until socat is actually listening on the socket before we exit.
778    // socat needs a moment after spawning before it accepts connections.
779    let deadline = Instant::now() + Duration::from_secs(15);
780    loop {
781        if UnixStream::connect(&socket_path).is_ok() {
782            break;
783        }
784        if Instant::now() >= deadline {
785            return Err(format!(
786                "timed out waiting for socket {} to become connectable",
787                socket_path.display()
788            )
789            .into());
790        }
791        std::thread::sleep(Duration::from_millis(100));
792    }
793
794    // IMPORTANT: prevent TestProcesses::drop() from calling kill_all().
795    // Without this the Drop impl would kill every child process the instant
796    // cmd_start_sim returns, tearing down the sim before the caller can use it.
797    std::mem::forget(processes);
798
799    println!("{}", socket_path.display());
800    Ok(())
801}
802
803/// Derive a stable TCP port in the private range (49152–65535) from the
804/// workspace path. Two different repos will get different ports, avoiding
805/// conflicts when both are running simultaneously.
806fn container_port(workspace: &str) -> u16 {
807    let hash = workspace
808        .bytes()
809        .fold(0u64, |h, b| h.wrapping_mul(31).wrapping_add(b as u64));
810    49152 + (hash % (65535 - 49152)) as u16
811}
812
813/// `cargo xtask start-sim --container` — ensure the dev container is running,
814/// then exec `start-sim` inside it so Linux-only sim processes stay alive after
815/// this command returns. The workspace bind-mount means the socket file appears
816/// in `tests/sockets/` on the host as well.
817fn cmd_start_sim_in_docker(sim_id: &str) -> Result<()> {
818    let root = workspace_root()?;
819    let workspace = root
820        .to_str()
821        .ok_or("Workspace path contains non-UTF-8 characters")?;
822
823    // Build the image if it doesn't already exist.
824    let image_exists = Command::new("docker")
825        .args(["image", "inspect", "--format", ".", DOCKER_IMAGE_TAG])
826        .stdout(Stdio::null())
827        .stderr(Stdio::null())
828        .status()
829        .map(|s| s.success())
830        .unwrap_or(false);
831
832    if !image_exists {
833        println!("Docker image {DOCKER_IMAGE_TAG} not found — building...");
834        docker_build()?;
835    }
836
837    // Derive a container name from the workspace path so each repo gets its
838    // own container with the correct workspace bind-mounted.
839    let hash = format!("{:x}", workspace.bytes().fold(0u64, |h, b| h.wrapping_mul(31).wrapping_add(b as u64)));
840    let container_name = format!("babble-bridge-{}", &hash[..8]);
841    let container_name = container_name.as_str();
842    let port = container_port(workspace);
843    let port_mapping = format!("127.0.0.1:{port}:{port}");
844
845    // Check if the container is already running.
846    let container_running = Command::new("docker")
847        .args(["inspect", "--format", "{{.State.Running}}", container_name])
848        .stdout(Stdio::piped())
849        .stderr(Stdio::null())
850        .output()
851        .map(|o| String::from_utf8_lossy(&o.stdout).trim() == "true")
852        .unwrap_or(false);
853
854    if !container_running {
855        // Remove any stopped container with the same name before starting fresh.
856        let _ = Command::new("docker")
857            .args(["rm", "-f", container_name])
858            .stdout(Stdio::null())
859            .stderr(Stdio::null())
860            .status();
861
862        println!("Starting container {container_name} (TCP bridge port {port})...");
863        let mount = format!("{workspace}:/workspace");
864        run_cmd(
865            "docker",
866            &[
867                "run",
868                "--detach",
869                "--tty",            // required: mounts /dev/pts so openpty() works for Zephyr UART PTY
870                "--name", container_name,
871                "--platform", "linux/amd64",
872                "-p", &port_mapping, // TCP bridge: accessible at 127.0.0.1:<port> on macOS
873                "-v", &mount,
874                "-w", "/workspace",
875                DOCKER_IMAGE_TAG,
876                "sleep", "infinity",  // keep the container alive
877            ],
878            Some(&root),
879        )?;
880    } else {
881        println!("Container {container_name} is already running.");
882    }
883
884    println!("Running start-sim inside container...");
885    // First stop any running sim so BabbleSim's IPC resources (shared memory,
886    // semaphores) are fully released before we try to start fresh ones.
887    let _ = run_cmd(
888        "docker",
889        &[
890            "exec",
891            container_name,
892            "bash", "-lc", &format!("cargo xtask stop-sim --sim-id {sim_id}"),
893        ],
894        Some(&root),
895    );
896    // Kill any previous TCP bridge for this sim.
897    let _ = run_cmd(
898        "docker",
899        &[
900            "exec",
901            container_name,
902            "bash", "-lc", &format!("pkill -f 'socat TCP-LISTEN:{port}' || true"),
903        ],
904        Some(&root),
905    );
906    run_cmd(
907        "docker",
908        &[
909            "exec",
910            container_name,
911            "bash", "-lc", &format!("cargo xtask start-sim --sim-id {sim_id}"),
912        ],
913        Some(&root),
914    )?;
915
916    // Launch a socat TCP bridge inside the container so the socket is
917    // reachable from macOS at 127.0.0.1:<port>.
918    let socket_path = format!("/workspace/tests/sockets/{sim_id}.sock");
919    run_cmd(
920        "docker",
921        &[
922            "exec",
923            "--detach",
924            container_name,
925            "bash", "-lc",
926            &format!("socat TCP-LISTEN:{port},reuseaddr,fork UNIX-CLIENT:{socket_path}"),
927        ],
928        Some(&root),
929    )?;
930
931    println!("TCP bridge ready: connect from macOS at 127.0.0.1:{port}");
932    Ok(())
933}
934
935/// `cargo xtask exec` — run an arbitrary command inside the existing sim
936/// container where the Unix socket is reachable.
937///
938/// Unlike `docker-run` (which spins up a fresh container), this execs into
939/// the persistent `babble-bridge-<hash>` container that `start-sim --container`
940/// created, so it shares the same network namespace and the socket created by
941/// `start-sim` is connectable.
942fn cmd_exec_in_container(cmd_args: &[&str]) -> Result<()> {
943    let root = workspace_root()?;
944    let workspace = root
945        .to_str()
946        .ok_or("Workspace path contains non-UTF-8 characters")?;
947    let hash = format!("{:x}", workspace.bytes().fold(0u64, |h, b| h.wrapping_mul(31).wrapping_add(b as u64)));
948    let container_name = format!("babble-bridge-{}", &hash[..8]);
949
950    let container_running = Command::new("docker")
951        .args(["inspect", "--format", "{{.State.Running}}", &container_name])
952        .stdout(Stdio::piped())
953        .stderr(Stdio::null())
954        .output()
955        .map(|o| String::from_utf8_lossy(&o.stdout).trim() == "true")
956        .unwrap_or(false);
957
958    if !container_running {
959        return Err(format!(
960            "Container {container_name} is not running. \
961             Start it first with `cargo xtask start-sim --container`."
962        ).into());
963    }
964
965    let shell_cmd = cmd_args.join(" ");
966    run_cmd(
967        "docker",
968        &["exec", &container_name, "bash", "-lc", &shell_cmd],
969        Some(&root),
970    )
971}
972
973/// `cargo xtask stop-sim` — kill all processes belonging to a running simulation.
974fn cmd_stop_sim(sim_id: &str) -> Result<()> {
975    crate::kill_stale_sim_processes(sim_id);
976    println!("Stopped simulation '{sim_id}'");
977    Ok(())
978}
979
980/// `cargo xtask clean-sockets` — remove all `*.sock` files from `tests/sockets/`.
981fn cmd_clean_sockets(root: &Path) -> Result<()> {
982    let sockets_dir = root.join("tests/sockets");
983    if !sockets_dir.exists() {
984        create_sockets_dir(root)?;
985        println!("No socket files found in {}.", sockets_dir.display());
986        return Ok(());
987    }
988
989    let mut removed = 0usize;
990    for entry in fs::read_dir(&sockets_dir)? {
991        let entry = entry?;
992        let path = entry.path();
993        if path.extension().and_then(|e| e.to_str()) == Some("sock") {
994            fs::remove_file(&path)?;
995            println!("Removed {}", path.display());
996            removed += 1;
997        }
998    }
999
1000    if removed == 0 {
1001        println!("No socket files found in {}.", sockets_dir.display());
1002    } else {
1003        println!("Removed {removed} socket file(s).");
1004    }
1005    Ok(())
1006}
1007
1008// ── BabbleSim runner ─────────────────────────────────────────────────────────
1009
1010fn bsim_ld_library_path() -> String {
1011    match env::var("LD_LIBRARY_PATH") {
1012        Ok(existing) => format!("external/tools/bsim/lib:{existing}"),
1013        Err(_) => "external/tools/bsim/lib".to_string(),
1014    }
1015}
1016
1017fn spawn_in_bsim_bin(sim_id: &str, exe: &str, args: &[&str]) -> Result<Child> {
1018    let bsim_bin = Path::new("external/tools/bsim/bin");
1019    Command::new(exe)
1020        .args(args)
1021        .current_dir(bsim_bin)
1022        .stdin(Stdio::inherit())
1023        .stdout(Stdio::inherit())
1024        .stderr(Stdio::inherit())
1025        .env("BSIM_OUT_PATH", "external/tools/bsim")
1026        .env("BSIM_COMPONENTS_PATH", "external/tools/bsim/components")
1027        .env("LD_LIBRARY_PATH", bsim_ld_library_path())
1028        .spawn()
1029        .map_err(|e| format!("Failed to spawn '{exe}' for sim '{sim_id}': {e}").into())
1030}
1031
1032fn pkill_sim(sim_id: &str) {
1033    for process in ["bs_2G4_phy_v1", "zephyr_rpc_server_app", "cgm_peripheral_sample"] {
1034        let pattern = format!("{process} -s={sim_id}");
1035        let _ = Command::new("pkill").args(["-f", &pattern]).status();
1036        let _ = Command::new("pkill").args(["-9", "-f", &pattern]).status();
1037    }
1038}
1039
1040fn generate_sim_id() -> String {
1041    use std::time::{SystemTime, UNIX_EPOCH};
1042    let nanos = SystemTime::now()
1043        .duration_since(UNIX_EPOCH)
1044        .unwrap_or_default()
1045        .subsec_nanos();
1046    let pid = std::process::id();
1047    format!("sim_{:08x}", nanos ^ (pid << 16))
1048}
1049
1050fn run_bsim(nrf_rpc_server: bool, cgm_peripheral: bool) -> Result<()> {
1051    let (run_nrf, run_cgm) = if !nrf_rpc_server && !cgm_peripheral {
1052        (true, true)
1053    } else {
1054        (nrf_rpc_server, cgm_peripheral)
1055    };
1056
1057    let sim_id = generate_sim_id();
1058    pkill_sim(&sim_id);
1059
1060    let _ = fs::remove_dir_all(format!(
1061        "/tmp/bs_{}/{}",
1062        env::var("USER").unwrap_or_default(),
1063        &sim_id
1064    ));
1065
1066    let device_count = (run_nrf as u32) + (run_cgm as u32);
1067
1068    const SEP: &str = "────────────────────────────────────────────────────────────";
1069
1070    println!("  Starting PHY simulator...");
1071    let _phy = spawn_in_bsim_bin(
1072        &sim_id,
1073        "./bs_2G4_phy_v1",
1074        &[
1075            &format!("-s={sim_id}"),
1076            &format!("-D={device_count}"),
1077            "-sim_length=86400e6",
1078        ],
1079    )?;
1080
1081    let nrf_device_idx: u32 = 0;
1082    let cgm_device_idx: u32 = run_nrf as u32;
1083
1084    let mut nrf_proc = if run_nrf {
1085        println!("  Starting nRF RPC server (device {nrf_device_idx})...");
1086        Some(spawn_in_bsim_bin(
1087            &sim_id,
1088            "./zephyr_rpc_server_app",
1089            &[
1090                &format!("-s={sim_id}"),
1091                &format!("-d={nrf_device_idx}"),
1092                "-uart0_pty",
1093                "-uart_pty_pollT=1000",
1094            ],
1095        )?)
1096    } else {
1097        None
1098    };
1099
1100    let mut cgm_proc = if run_cgm {
1101        println!("  Starting CGM peripheral (device {cgm_device_idx})...");
1102        let cgm_log = fs::File::create("external/tools/bsim/bin/cgm_peripheral_sample.log")?;
1103        Some(
1104            Command::new("./cgm_peripheral_sample")
1105                .args([&format!("-s={sim_id}"), &format!("-d={cgm_device_idx}")])
1106                .current_dir("external/tools/bsim/bin")
1107                .stdin(Stdio::null())
1108                .stdout(cgm_log.try_clone()?)
1109                .stderr(cgm_log)
1110                .env("BSIM_OUT_PATH", "external/tools/bsim")
1111                .env("BSIM_COMPONENTS_PATH", "external/tools/bsim/components")
1112                .env("LD_LIBRARY_PATH", bsim_ld_library_path())
1113                .spawn()?,
1114        )
1115    } else {
1116        None
1117    };
1118
1119    let mut device_list = Vec::new();
1120    if run_nrf { device_list.push(format!("nrf-rpc-server [d={nrf_device_idx}]")); }
1121    if run_cgm { device_list.push(format!("cgm-peripheral [d={cgm_device_idx}]")); }
1122    let device_str = device_list.join(", ");
1123
1124    println!();
1125    println!("{SEP}");
1126    println!("  Simulation ID : {sim_id}");
1127    println!("  Devices       : {device_str}");
1128    println!("  Duration      : 86400 s  (~24 h simulated, ~39 s real time)");
1129    println!("{SEP}");
1130
1131    if run_nrf {
1132        println!();
1133        println!("  To test RX, run in another terminal:");
1134        println!();
1135        println!("    socat UNIX-LISTEN:/tmp/nrf_rpc_server.sock,fork /dev/pts/XX,raw,echo=0");
1136        println!("    printf '\\x04\\x00\\xff\\x00\\xff\\x00\\x62\\x74\\x5f\\x72\\x70\\x63' \\");
1137        println!("      | socat - UNIX-CONNECT:/tmp/nrf_rpc_server.sock");
1138    }
1139
1140    println!();
1141    println!("  Press Ctrl+C to stop.");
1142    println!();
1143
1144    if let Some(ref mut proc) = nrf_proc {
1145        let status = proc.wait()?;
1146        if let Some(ref mut cgm) = cgm_proc {
1147            let _ = cgm.kill();
1148        }
1149        if !status.success() {
1150            return Err(format!("zephyr_rpc_server_app exited with status: {status}").into());
1151        }
1152    } else if let Some(ref mut proc) = cgm_proc {
1153        let status = proc.wait()?;
1154        if !status.success() {
1155            return Err(format!("cgm_peripheral_sample exited with status: {status}").into());
1156        }
1157    }
1158
1159    Ok(())
1160}