Skip to main content

rbnx/cmd/
codegen.rs

1// SPDX-License-Identifier: MulanPSL-2.0
2// `rbnx codegen -p <package>` — one-shot codegen wrapper that replaces
3// the copy-pasted robonix-codegen + grpc_tools.protoc boilerplate in each
4// package's build.sh.
5//
6// For a given package root:
7//   1. Stage the system-wide .proto files (IDL + capabilities) into
8//      `<pkg>/rbnx-build/proto-staging/`. This dir is package-local and
9//      transient — no committed artefacts elsewhere in the tree.
10//   2. If --mcp: regenerate `<pkg>/robonix_mcp_types/`
11//      (robonix-codegen --lang mcp).
12//   3. Run grpc_tools.protoc on the staged + runtime protos, emitting
13//      `<pkg>/proto_gen/`.
14//   4. Write `<pkg>/rbnx-build/ws/install/setup.bash` so `rbnx start`
15//      injects the right PYTHONPATH.
16//
17// TODO: union package-local capabilities (`<pkg>/capabilities/`) and
18// package-local IDL (`<pkg>/interfaces/lib/`) into a staging dir before
19// running codegen — currently those are a copy-pasted snippet in a few
20// build.sh scripts.
21
22use anyhow::{Context, Result};
23use colored::*;
24use robonix_cli::{Config, SourcePathKey};
25use std::path::{Path, PathBuf};
26use std::process::Command;
27
28fn run_cmd(label: &str, cmd: &mut Command) -> Result<()> {
29    log::debug!("[codegen] {}: {:?}", label, cmd);
30    let status = cmd
31        .status()
32        .with_context(|| format!("failed to execute `{label}`"))?;
33    if !status.success() {
34        anyhow::bail!("{label} failed with {status}");
35    }
36    Ok(())
37}
38
39fn resolve_pkg_root(package: &Path) -> Result<PathBuf> {
40    let abs = if package.is_absolute() {
41        package.to_path_buf()
42    } else {
43        let base = std::env::var("RBNX_INVOCATION_CWD")
44            .map(PathBuf::from)
45            .unwrap_or(std::env::current_dir()?);
46        base.join(package)
47    };
48    let abs = abs
49        .canonicalize()
50        .with_context(|| format!("package path not found: {}", abs.display()))?;
51    if !abs.join("package_manifest.yaml").exists()
52        && !abs.join("robonix_manifest.yaml").exists()
53        && !abs.join("rbnx_manifest.yaml").exists()
54    {
55        eprintln!(
56            "{}: {} has no package_manifest.yaml (continuing anyway)",
57            "warn".yellow().bold(),
58            abs.display()
59        );
60    }
61    Ok(abs)
62}
63
64pub async fn execute(
65    config: Config,
66    package: Option<PathBuf>,
67    mcp: bool,
68    ros2: bool,
69    clean: bool,
70    out_dir: Option<PathBuf>,
71) -> Result<()> {
72    let pkg_root = match package {
73        Some(p) => resolve_pkg_root(&p)?,
74        None => super::run_package::find_package_from_cwd()?,
75    };
76    let rust_root = config.resolve_source_path(SourcePathKey::RustRoot)?;
77    // <root>/capabilities — contract TOMLs (top level) + IDL under lib/.
78    let capabilities_dir = config.resolve_source_path(SourcePathKey::Capabilities)?;
79    // <root>/capabilities/lib — single canonical IDL search root.
80    // msg/srv references in contract TOMLs (e.g. `demo/srv/Hello`)
81    // resolve as `<root>/capabilities/lib/demo/srv/Hello.srv`.
82    let interfaces_lib = config.resolve_source_path(SourcePathKey::InterfacesLib)?;
83    let runtime_proto = config.resolve_source_path(SourcePathKey::RuntimeProto)?;
84    // Per-package overlay: `<pkg>/capabilities/` mirrors the global
85    // layout. When present we add it both as an IDL include
86    // (`<pkg>/capabilities/lib/`) and as a contracts root, so packages
87    // can ship their own msg/srv/contracts that merge with the global
88    // set (symmetric with how atlas's contract registry merges roots).
89    let pkg_caps = pkg_root.join("capabilities");
90    let pkg_caps_lib: Option<PathBuf> = {
91        let p = pkg_caps.join("lib");
92        p.is_dir().then_some(p)
93    };
94    // <pkg>/capabilities/ also gets passed to --contracts so per-package
95    // contracts merge with the global tree (atlas does the same merge
96    // at the registry level — this keeps codegen consistent).
97    let pkg_caps_root: Option<PathBuf> = pkg_caps.is_dir().then_some(pkg_caps);
98
99    // Codegen output convention: every package gets
100    // `<pkg>/rbnx-build/codegen/{proto_gen, robonix_mcp_types}`. Both robonix_api
101    // and rbnx-cli rely on this exact layout, so packages don't need to plumb
102    // paths anywhere — `rbnx codegen -p $PKG` is the whole story. The
103    // `--out-dir` flag stays as an escape hatch for unusual layouts but
104    // defaults to the convention.
105    let rbnx_build = pkg_root.join("rbnx-build");
106    let out_root = match out_dir {
107        Some(d) if d.is_absolute() => d,
108        Some(d) => pkg_root.join(d),
109        None => rbnx_build.join("codegen"),
110    };
111    let proto_gen = out_root.join("proto_gen");
112    let mcp_types = out_root.join("robonix_mcp_types");
113    // ROS 2 canonical message overlay (a colcon workspace of source). Only
114    // generated with --ros2; consumers colcon-build it and source
115    // install/setup.bash so their rclpy types are Robonix's.
116    let ros2_idl = out_root.join("ros2_idl");
117    // Per-invocation staging for the system-wide .proto files. No commits;
118    // grpc_tools.protoc reads from here in step 3.
119    let proto_staging = rbnx_build.join("proto-staging");
120
121    if clean {
122        for p in [&proto_gen, &mcp_types, &rbnx_build] {
123            if p.exists() {
124                std::fs::remove_dir_all(p).ok();
125            }
126        }
127    }
128    std::fs::create_dir_all(&proto_staging)?;
129
130    // Prefer the installed `robonix-codegen` binary on PATH (or in the
131    // workspace target dir) — `cargo run -p robonix-codegen` rebuilds /
132    // re-resolves the workspace on every invocation, which adds 100-300 ms
133    // even when nothing changed and floods the boot log with `Compiling…`
134    // lines that don't belong in a per-package codegen step. Falls back
135    // to `cargo run` only if neither binary is available, so a fresh
136    // checkout that hasn't done `cargo install` keeps working.
137    let direct_codegen = locate_codegen_bin(&rust_root);
138    let cargo_bin = if direct_codegen.is_none() {
139        if Path::new("/usr/bin/cargo").exists() {
140            Some("/usr/bin/cargo".to_string())
141        } else {
142            Some(std::env::var("CARGO").unwrap_or_else(|_| "cargo".to_string()))
143        }
144    } else {
145        None
146    };
147
148    println!("{} package: {}", "[codegen]".bold(), pkg_root.display());
149    println!(
150        "{} robonix source: {}",
151        "[codegen]".bold(),
152        rust_root.display()
153    );
154
155    // 1. Stage system .proto into rbnx-build/proto-staging/.
156    //    Includes: global <root>/capabilities/lib + (if present) per-package
157    //    <pkg>/capabilities/lib. --contracts: global capabilities tree
158    //    (per-package contracts merging through codegen is a follow-up).
159    println!("{} robonix-codegen --lang proto ...", "[codegen]".bold());
160    let mut proto_cmd =
161        build_codegen_cmd(direct_codegen.as_ref(), cargo_bin.as_deref(), &rust_root);
162    proto_cmd
163        .args(["--lang", "proto", "-I"])
164        .arg(&interfaces_lib);
165    if let Some(p) = pkg_caps_lib.as_ref() {
166        proto_cmd.arg("-I").arg(p);
167    }
168    proto_cmd.arg("--contracts").arg(&capabilities_dir);
169    if let Some(p) = pkg_caps_root.as_ref() {
170        proto_cmd.arg("--contracts").arg(p);
171    }
172    proto_cmd.arg("-o").arg(&proto_staging);
173    run_cmd("robonix-codegen proto", &mut proto_cmd)?;
174
175    // 2. Optional: MCP dataclasses.
176    if mcp {
177        println!("{} robonix-codegen --lang mcp ...", "[codegen]".bold());
178        std::fs::create_dir_all(&mcp_types).ok();
179        let mut mcp_cmd =
180            build_codegen_cmd(direct_codegen.as_ref(), cargo_bin.as_deref(), &rust_root);
181        mcp_cmd.args(["--lang", "mcp", "-I"]).arg(&interfaces_lib);
182        // Include per-package <pkg>/capabilities/lib too — same merge
183        // semantics as the proto step, so per-pkg srv files (e.g.
184        // explore_rbnx's Explore.srv) emit their MCP Request/Response
185        // dataclasses alongside the global ones.
186        if let Some(p) = pkg_caps_lib.as_ref() {
187            mcp_cmd.arg("-I").arg(p);
188        }
189        mcp_cmd.arg("-o").arg(&mcp_types);
190        run_cmd("robonix-codegen mcp", &mut mcp_cmd)?;
191    }
192
193    // 3. Package-local Python stubs via grpc_tools.protoc.
194    //
195    // We shell out to the active python3's `grpcio-tools` package because
196    // generating Python `_pb2.py` + `_pb2_grpc.py` from `.proto` is what
197    // the standard protoc-with-grpc-python-plugin combo is designed to
198    // do, and there's no maintained Rust crate that emits Python stubs.
199    // Probe for both python3 and the module up front — the historical
200    // silent-ignore on failure left packages with "0 generated Servicers"
201    // at runtime and a debug session per missing dep.
202    probe_python_grpc_tools()?;
203
204    println!(
205        "{} grpc_tools.protoc → {}",
206        "[codegen]".bold(),
207        proto_gen.display()
208    );
209    std::fs::create_dir_all(&proto_gen)?;
210    let proto_files: Vec<PathBuf> = std::fs::read_dir(&runtime_proto)?
211        .chain(std::fs::read_dir(&proto_staging)?)
212        .filter_map(|e| e.ok().map(|e| e.path()))
213        .filter(|p| p.extension().and_then(|s| s.to_str()) == Some("proto"))
214        .collect();
215
216    let mut protoc = Command::new("python3");
217    protoc
218        .args(["-m", "grpc_tools.protoc", "-I"])
219        .arg(&runtime_proto)
220        .arg("-I")
221        .arg(&proto_staging)
222        .arg(format!("--python_out={}", proto_gen.display()))
223        .arg(format!("--grpc_python_out={}", proto_gen.display()));
224    for f in &proto_files {
225        protoc.arg(f);
226    }
227    let status = protoc
228        .status()
229        .with_context(|| "failed to spawn python3 -m grpc_tools.protoc")?;
230    if !status.success() {
231        anyhow::bail!(
232            "python3 -m grpc_tools.protoc failed with {status}. \
233             Re-run with -v / RUST_LOG=debug to see protoc output."
234        );
235    }
236
237    // 3b. Optional: ROS 2 canonical message overlay (source). Emitted next
238    //     to proto_gen / robonix_mcp_types so it follows the same rbnx-build
239    //     convention. It still needs `colcon build` in a ROS 2 environment;
240    //     the package's build.sh does that (e.g. docker exec into the
241    //     container) and start.sh sources <ros2_idl>/install/setup.bash.
242    if ros2 {
243        println!("{} robonix-codegen --lang ros2 ...", "[codegen]".bold());
244        let mut ros2_cmd =
245            build_codegen_cmd(direct_codegen.as_ref(), cargo_bin.as_deref(), &rust_root);
246        ros2_cmd.args(["--lang", "ros2", "-I"]).arg(&interfaces_lib);
247        if let Some(p) = pkg_caps_lib.as_ref() {
248            ros2_cmd.arg("-I").arg(p);
249        }
250        ros2_cmd.arg("-o").arg(&ros2_idl);
251        run_cmd("robonix-codegen ros2", &mut ros2_cmd)?;
252    }
253
254    // 4. Write PYTHONPATH setup stub so `rbnx start` sees all the right paths.
255    let ws_install = rbnx_build.join("ws").join("install");
256    std::fs::create_dir_all(&ws_install)?;
257    let mut py_parts: Vec<String> = vec![
258        pkg_root.display().to_string(),
259        proto_gen.display().to_string(),
260    ];
261    if mcp {
262        py_parts.push(mcp_types.display().to_string());
263    }
264    let joined = py_parts.join(":");
265    let setup_bash = ws_install.join("setup.bash");
266    std::fs::write(
267        &setup_bash,
268        format!("#!/usr/bin/env bash\nexport PYTHONPATH=\"{joined}:${{PYTHONPATH:-}}\"\n"),
269    )?;
270
271    println!(
272        "{} done — {}, {} setup.bash",
273        "[codegen]".green().bold(),
274        if mcp {
275            "proto+mcp+stubs"
276        } else {
277            "proto+stubs"
278        },
279        if mcp_types.exists() {
280            "mcp_types, "
281        } else {
282            ""
283        }
284    );
285    Ok(())
286}
287
288/// Locate a runnable `robonix-codegen` binary without going through cargo.
289/// Search order:
290///   1. `$ROBONIX_CODEGEN_BIN` (override for unusual layouts)
291///   2. `$CARGO_HOME/bin/robonix-codegen` / `~/.cargo/bin/robonix-codegen`
292///      — what `cargo install --path crates/robonix-codegen` puts there
293///   3. `<rust_root>/target/release/robonix-codegen`
294///   4. `<rust_root>/target/debug/robonix-codegen`
295///   5. anything matching `robonix-codegen` on `$PATH`
296///
297/// Returns `None` only when none of those exist; callers fall back to
298/// `cargo run -p robonix-codegen` which keeps a fresh-checkout workflow
299/// alive even before the user has installed any binaries.
300fn locate_codegen_bin(rust_root: &Path) -> Option<PathBuf> {
301    if let Ok(s) = std::env::var("ROBONIX_CODEGEN_BIN")
302        && !s.is_empty()
303    {
304        let p = PathBuf::from(s);
305        if p.is_file() {
306            return Some(p);
307        }
308    }
309    let cargo_home = std::env::var("CARGO_HOME")
310        .map(PathBuf::from)
311        .ok()
312        .or_else(|| dirs::home_dir().map(|h| h.join(".cargo")));
313    if let Some(home) = cargo_home {
314        let p = home.join("bin").join("robonix-codegen");
315        if p.is_file() {
316            return Some(p);
317        }
318    }
319    for profile in ["release", "debug"] {
320        let p = rust_root
321            .join("target")
322            .join(profile)
323            .join("robonix-codegen");
324        if p.is_file() {
325            return Some(p);
326        }
327    }
328    if let Ok(path_env) = std::env::var("PATH") {
329        for dir in std::env::split_paths(&path_env) {
330            let p = dir.join("robonix-codegen");
331            if p.is_file() {
332                return Some(p);
333            }
334        }
335    }
336    None
337}
338
339/// Probe the active python3 for `grpc_tools.protoc`. Bails with a single,
340/// copy-pasteable install instruction if either is missing — this is the
341/// only Python dep `rbnx codegen` reaches for, so making it explicit up
342/// front is the entire UX cost of not vendoring protoc + grpc_python_plugin.
343fn probe_python_grpc_tools() -> Result<()> {
344    let py = Command::new("python3").arg("--version").output();
345    if py.is_err() || !py.as_ref().unwrap().status.success() {
346        anyhow::bail!(
347            "python3 not found on PATH. `rbnx codegen` shells out to \
348             `python3 -m grpc_tools.protoc` to emit Python gRPC stubs. \
349             Install python3 (>=3.10) and re-run."
350        );
351    }
352    let mod_probe = Command::new("python3")
353        .args(["-c", "import grpc_tools.protoc"])
354        .output()
355        .with_context(|| "failed to spawn python3 for grpc_tools probe")?;
356    if !mod_probe.status.success() {
357        let py_path = Command::new("python3")
358            .args(["-c", "import sys; print(sys.executable)"])
359            .output()
360            .ok()
361            .and_then(|o| String::from_utf8(o.stdout).ok())
362            .map(|s| s.trim().to_string())
363            .unwrap_or_else(|| "python3".to_string());
364        anyhow::bail!(
365            "Python module 'grpc_tools' not importable from {py_path}.\n\
366             `rbnx codegen` needs grpcio-tools to emit Python `_pb2.py` + `_pb2_grpc.py`.\n\
367             Install into the python3 above:\n\
368             \n    python3 -m pip install --user grpcio-tools\n"
369        );
370    }
371    Ok(())
372}
373
374/// Build a fresh `Command` invoking `robonix-codegen` either directly
375/// (preferred — picks up `$ROBONIX_CODEGEN_BIN` / installed bin / target/
376/// in that order) or via `cargo run -p robonix-codegen` as a last
377/// resort. Caller appends the actual `--lang … -I … -o …` args.
378fn build_codegen_cmd(direct: Option<&PathBuf>, cargo: Option<&str>, rust_root: &Path) -> Command {
379    if let Some(bin) = direct {
380        Command::new(bin)
381    } else {
382        let cargo = cargo.expect("either direct codegen bin or cargo bin must be set");
383        let mut cmd = Command::new(cargo);
384        cmd.args(["run", "-p", "robonix-codegen", "--manifest-path"])
385            .arg(rust_root.join("Cargo.toml"))
386            .arg("--");
387        cmd
388    }
389}