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>/rbnx-build/codegen/proto_gen/`.
14//
15// Codegen owns only `rbnx-build/codegen/` and `rbnx-build/proto-staging/`.
16// In particular, it must never create, overwrite, or remove the colcon
17// workspace under `rbnx-build/ws/`; `rbnx start` injects generated Python
18// paths directly and sources a real colcon overlay when one exists.
19//
20// TODO: union package-local capabilities (`<pkg>/capabilities/`) and
21// package-local IDL (`<pkg>/interfaces/lib/`) into a staging dir before
22// running codegen — currently those are a copy-pasted snippet in a few
23// build.sh scripts.
24
25use anyhow::{Context, Result};
26use colored::*;
27use robonix_cli::{Config, SourcePathKey};
28use robonix_scribe::debug;
29use std::path::{Path, PathBuf};
30use std::process::Command;
31
32pub(crate) fn run_cmd(label: &str, cmd: &mut Command) -> Result<()> {
33    debug!("[codegen] {}: {:?}", label, cmd);
34    let status = cmd
35        .status()
36        .with_context(|| format!("failed to execute `{label}`"))?;
37    if !status.success() {
38        anyhow::bail!("{label} failed with {status}");
39    }
40    Ok(())
41}
42
43fn resolve_pkg_root(package: &Path) -> Result<PathBuf> {
44    let abs = if package.is_absolute() {
45        package.to_path_buf()
46    } else {
47        let base = std::env::var("RBNX_INVOCATION_CWD")
48            .map(PathBuf::from)
49            .unwrap_or(std::env::current_dir()?);
50        base.join(package)
51    };
52    let abs = abs
53        .canonicalize()
54        .with_context(|| format!("package path not found: {}", abs.display()))?;
55    if !abs.join("package_manifest.yaml").exists()
56        && !abs.join("robonix_manifest.yaml").exists()
57        && !abs.join("rbnx_manifest.yaml").exists()
58    {
59        eprintln!(
60            "{}: {} has no package_manifest.yaml (continuing anyway)",
61            "warn".yellow().bold(),
62            abs.display()
63        );
64    }
65    Ok(abs)
66}
67
68pub async fn execute(
69    config: Config,
70    package: Option<PathBuf>,
71    mcp: bool,
72    ros2: bool,
73    clean: bool,
74    out_dir: Option<PathBuf>,
75) -> Result<()> {
76    let pkg_root = match package {
77        Some(p) => resolve_pkg_root(&p)?,
78        None => super::run_package::find_package_from_cwd()?,
79    };
80    let rust_root = config.resolve_source_path(SourcePathKey::RustRoot)?;
81    // <root>/capabilities — contract TOMLs (top level) + IDL under lib/.
82    let capabilities_dir = config.resolve_source_path(SourcePathKey::Capabilities)?;
83    // <root>/capabilities/lib — single canonical IDL search root.
84    // msg/srv references in contract TOMLs (e.g. `demo/srv/Hello`)
85    // resolve as `<root>/capabilities/lib/demo/srv/Hello.srv`.
86    let interfaces_lib = config.resolve_source_path(SourcePathKey::InterfacesLib)?;
87    let runtime_proto = config.resolve_source_path(SourcePathKey::RuntimeProto)?;
88    // Per-package overlay: `<pkg>/capabilities/` mirrors the global
89    // layout. When present we add it both as an IDL include
90    // (`<pkg>/capabilities/lib/`) and as a contracts root, so packages
91    // can ship their own msg/srv/contracts that merge with the global
92    // set (symmetric with how atlas's contract registry merges roots).
93    let pkg_caps = pkg_root.join("capabilities");
94    let pkg_caps_lib: Option<PathBuf> = {
95        let p = pkg_caps.join("lib");
96        p.is_dir().then_some(p)
97    };
98    // <pkg>/capabilities/ also gets passed to --contracts so per-package
99    // contracts merge with the global tree (atlas does the same merge
100    // at the registry level — this keeps codegen consistent).
101    let pkg_caps_root: Option<PathBuf> = pkg_caps.is_dir().then_some(pkg_caps);
102
103    // Codegen output convention: every package gets
104    // `<pkg>/rbnx-build/codegen/{proto_gen, robonix_mcp_types}`. Both robonix_api
105    // and rbnx-cli rely on this exact layout, so packages don't need to plumb
106    // paths anywhere — `rbnx codegen -p $PKG` is the whole story. The
107    // `--out-dir` flag stays as an escape hatch for unusual layouts but
108    // defaults to the convention.
109    let rbnx_build = pkg_root.join("rbnx-build");
110    let out_root = match out_dir {
111        Some(d) if d.is_absolute() => d,
112        Some(d) => pkg_root.join(d),
113        None => rbnx_build.join("codegen"),
114    };
115    let proto_gen = out_root.join("proto_gen");
116    let mcp_types = out_root.join("robonix_mcp_types");
117    // ROS 2 canonical message overlay (a colcon workspace of source). Only
118    // generated with --ros2; consumers colcon-build it and source
119    // install/setup.bash so their rclpy types are Robonix's.
120    let ros2_idl = out_root.join("ros2_idl");
121    // Per-invocation staging for the system-wide .proto files. No commits;
122    // grpc_tools.protoc reads from here in step 3.
123    let proto_staging = rbnx_build.join("proto-staging");
124
125    if clean {
126        clean_codegen_outputs([&proto_gen, &mcp_types, &ros2_idl, &proto_staging])?;
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    println!(
255        "{} done — {}{}",
256        "[codegen]".green().bold(),
257        if mcp {
258            "proto+mcp+stubs"
259        } else {
260            "proto+stubs"
261        },
262        if mcp_types.exists() {
263            format!(" in {}", out_root.display())
264        } else {
265            String::new()
266        }
267    );
268    Ok(())
269}
270
271fn clean_codegen_outputs<'a>(paths: impl IntoIterator<Item = &'a PathBuf>) -> Result<()> {
272    for path in paths {
273        if path.exists() {
274            std::fs::remove_dir_all(path)
275                .with_context(|| format!("remove codegen output {}", path.display()))?;
276        }
277    }
278    Ok(())
279}
280
281/// Locate a runnable `robonix-codegen` binary without going through cargo.
282/// Search order:
283///   1. `$ROBONIX_CODEGEN_BIN` (override for unusual layouts)
284///   2. `$CARGO_HOME/bin/robonix-codegen` / `~/.cargo/bin/robonix-codegen`
285///      — what `cargo install --path crates/robonix-codegen` puts there
286///   3. `<rust_root>/target/release/robonix-codegen`
287///   4. `<rust_root>/target/debug/robonix-codegen`
288///   5. anything matching `robonix-codegen` on `$PATH`
289///
290/// Returns `None` only when none of those exist; callers fall back to
291/// `cargo run -p robonix-codegen` which keeps a fresh-checkout workflow
292/// alive even before the user has installed any binaries.
293pub(crate) fn locate_codegen_bin(rust_root: &Path) -> Option<PathBuf> {
294    if let Ok(s) = std::env::var("ROBONIX_CODEGEN_BIN")
295        && !s.is_empty()
296    {
297        let p = PathBuf::from(s);
298        if p.is_file() {
299            return Some(p);
300        }
301    }
302    let cargo_home = std::env::var("CARGO_HOME")
303        .map(PathBuf::from)
304        .ok()
305        .or_else(|| dirs::home_dir().map(|h| h.join(".cargo")));
306    if let Some(home) = cargo_home {
307        let p = home.join("bin").join("robonix-codegen");
308        if p.is_file() {
309            return Some(p);
310        }
311    }
312    for profile in ["release", "debug"] {
313        let p = rust_root
314            .join("target")
315            .join(profile)
316            .join("robonix-codegen");
317        if p.is_file() {
318            return Some(p);
319        }
320    }
321    if let Ok(path_env) = std::env::var("PATH") {
322        for dir in std::env::split_paths(&path_env) {
323            let p = dir.join("robonix-codegen");
324            if p.is_file() {
325                return Some(p);
326            }
327        }
328    }
329    None
330}
331
332/// Probe the active python3 for `grpc_tools.protoc`. Bails with a single,
333/// copy-pasteable install instruction if either is missing — this is the
334/// only Python dep `rbnx codegen` reaches for, so making it explicit up
335/// front is the entire UX cost of not vendoring protoc + grpc_python_plugin.
336fn probe_python_grpc_tools() -> Result<()> {
337    let py = Command::new("python3").arg("--version").output();
338    if py.is_err() || !py.as_ref().unwrap().status.success() {
339        anyhow::bail!(
340            "python3 not found on PATH. `rbnx codegen` shells out to \
341             `python3 -m grpc_tools.protoc` to emit Python gRPC stubs. \
342             Install python3 (>=3.10) and re-run."
343        );
344    }
345    let mod_probe = Command::new("python3")
346        .args(["-c", "import grpc_tools.protoc"])
347        .output()
348        .with_context(|| "failed to spawn python3 for grpc_tools probe")?;
349    if !mod_probe.status.success() {
350        let py_path = Command::new("python3")
351            .args(["-c", "import sys; print(sys.executable)"])
352            .output()
353            .ok()
354            .and_then(|o| String::from_utf8(o.stdout).ok())
355            .map(|s| s.trim().to_string())
356            .unwrap_or_else(|| "python3".to_string());
357        anyhow::bail!(
358            "Python module 'grpc_tools' not importable from {py_path}.\n\
359             `rbnx codegen` needs grpcio-tools to emit Python `_pb2.py` + `_pb2_grpc.py`.\n\
360             Install into the python3 above:\n\
361             \n    python3 -m pip install --user grpcio-tools\n"
362        );
363    }
364    Ok(())
365}
366
367/// Build a fresh `Command` invoking `robonix-codegen` either directly
368/// (preferred — picks up `$ROBONIX_CODEGEN_BIN` / installed bin / target/
369/// in that order) or via `cargo run -p robonix-codegen` as a last
370/// resort. Caller appends the actual `--lang … -I … -o …` args.
371pub(crate) fn build_codegen_cmd(
372    direct: Option<&PathBuf>,
373    cargo: Option<&str>,
374    rust_root: &Path,
375) -> Command {
376    if let Some(bin) = direct {
377        Command::new(bin)
378    } else {
379        let cargo = cargo.expect("either direct codegen bin or cargo bin must be set");
380        let mut cmd = Command::new(cargo);
381        cmd.args(["run", "-p", "robonix-codegen", "--manifest-path"])
382            .arg(rust_root.join("Cargo.toml"))
383            .arg("--");
384        cmd
385    }
386}
387
388#[cfg(test)]
389mod tests {
390    use super::*;
391    use std::fs;
392    use std::time::{SystemTime, UNIX_EPOCH};
393
394    fn temp_root(label: &str) -> PathBuf {
395        let nonce = SystemTime::now()
396            .duration_since(UNIX_EPOCH)
397            .unwrap()
398            .as_nanos();
399        std::env::temp_dir().join(format!(
400            "rbnx-codegen-{label}-{}-{nonce}",
401            std::process::id()
402        ))
403    }
404
405    #[test]
406    fn clean_preserves_existing_colcon_workspace() {
407        let root = temp_root("preserve-colcon");
408        let codegen = root.join("rbnx-build/codegen");
409        let staging = root.join("rbnx-build/proto-staging");
410        let setup = root.join("rbnx-build/ws/install/setup.bash");
411        let original = b"# generated by colcon\nCOLCON_CURRENT_PREFIX=/real/overlay\n";
412
413        fs::create_dir_all(&codegen).unwrap();
414        fs::create_dir_all(&staging).unwrap();
415        fs::create_dir_all(setup.parent().unwrap()).unwrap();
416        fs::write(codegen.join("generated.py"), "generated").unwrap();
417        fs::write(staging.join("contracts.proto"), "staged").unwrap();
418        fs::write(&setup, original).unwrap();
419
420        clean_codegen_outputs([&codegen, &staging]).unwrap();
421
422        assert!(!codegen.exists());
423        assert!(!staging.exists());
424        assert_eq!(fs::read(&setup).unwrap(), original);
425        fs::remove_dir_all(root).unwrap();
426    }
427}