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