1use 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 let capabilities_dir = config.resolve_source_path(SourcePathKey::Capabilities)?;
83 let interfaces_lib = config.resolve_source_path(SourcePathKey::InterfacesLib)?;
87 let runtime_proto = config.resolve_source_path(SourcePathKey::RuntimeProto)?;
88 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 let pkg_caps_root: Option<PathBuf> = pkg_caps.is_dir().then_some(pkg_caps);
102
103 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 let ros2_idl = out_root.join("ros2_idl");
121 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 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 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 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 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 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 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
281pub(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
332fn 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
367pub(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}