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 sha2::{Digest, Sha256};
30use std::path::{Path, PathBuf};
31use std::process::{Command, Output};
32use std::time::{SystemTime, UNIX_EPOCH};
33
34pub(crate) fn run_cmd(label: &str, cmd: &mut Command) -> Result<()> {
35    debug!("[codegen] {}: {:?}", label, cmd);
36    let status = cmd
37        .status()
38        .with_context(|| format!("failed to execute `{label}`"))?;
39    if !status.success() {
40        anyhow::bail!("{label} failed with {status}");
41    }
42    Ok(())
43}
44
45fn resolve_pkg_root(package: &Path) -> Result<PathBuf> {
46    let abs = if package.is_absolute() {
47        package.to_path_buf()
48    } else {
49        let base = std::env::var("RBNX_INVOCATION_CWD")
50            .map(PathBuf::from)
51            .unwrap_or(std::env::current_dir()?);
52        base.join(package)
53    };
54    let abs = abs
55        .canonicalize()
56        .with_context(|| format!("package path not found: {}", abs.display()))?;
57    if !abs.join("package_manifest.yaml").exists()
58        && !abs.join("robonix_manifest.yaml").exists()
59        && !abs.join("rbnx_manifest.yaml").exists()
60    {
61        eprintln!(
62            "{}: {} has no package_manifest.yaml (continuing anyway)",
63            "warn".yellow().bold(),
64            abs.display()
65        );
66    }
67    Ok(abs)
68}
69
70pub async fn execute(
71    config: Config,
72    package: Option<PathBuf>,
73    mcp: bool,
74    ros2: bool,
75    clean: bool,
76    out_dir: Option<PathBuf>,
77    python: Option<PathBuf>,
78) -> Result<()> {
79    let pkg_root = match package {
80        Some(p) => resolve_pkg_root(&p)?,
81        None => super::run_package::find_package_from_cwd()?,
82    };
83    let rust_root = config.resolve_source_path(SourcePathKey::RustRoot)?;
84    // <root>/capabilities — contract TOMLs (top level) + IDL under lib/.
85    let capabilities_dir = config.resolve_source_path(SourcePathKey::Capabilities)?;
86    // <root>/capabilities/lib — single canonical IDL search root.
87    // msg/srv references in contract TOMLs (e.g. `demo/srv/Hello`)
88    // resolve as `<root>/capabilities/lib/demo/srv/Hello.srv`.
89    let interfaces_lib = config.resolve_source_path(SourcePathKey::InterfacesLib)?;
90    let runtime_proto = config.resolve_source_path(SourcePathKey::RuntimeProto)?;
91    // Per-package overlay: `<pkg>/capabilities/` mirrors the global
92    // layout. When present we add it both as an IDL include
93    // (`<pkg>/capabilities/lib/`) and as a contracts root, so packages
94    // can ship their own msg/srv/contracts that merge with the global
95    // set (symmetric with how atlas's contract registry merges roots).
96    let pkg_caps = pkg_root.join("capabilities");
97    let pkg_caps_lib: Option<PathBuf> = {
98        let p = pkg_caps.join("lib");
99        p.is_dir().then_some(p)
100    };
101    // <pkg>/capabilities/ also gets passed to --contracts so per-package
102    // contracts merge with the global tree (atlas does the same merge
103    // at the registry level — this keeps codegen consistent).
104    let pkg_caps_root: Option<PathBuf> = pkg_caps.is_dir().then_some(pkg_caps);
105
106    // Codegen output convention: every package gets
107    // `<pkg>/rbnx-build/codegen/{proto_gen, robonix_mcp_types}`. Both robonix_api
108    // and rbnx-cli rely on this exact layout, so packages don't need to plumb
109    // paths anywhere — `rbnx codegen -p $PKG` is the whole story. The
110    // `--out-dir` flag stays as an escape hatch for unusual layouts but
111    // defaults to the convention.
112    let rbnx_build = pkg_root.join("rbnx-build");
113    let out_root = match out_dir {
114        Some(d) if d.is_absolute() => d,
115        Some(d) => pkg_root.join(d),
116        None => rbnx_build.join("codegen"),
117    };
118    let proto_gen = out_root.join("proto_gen");
119    let mcp_types = out_root.join("robonix_mcp_types");
120    // ROS 2 canonical message overlay (a colcon workspace of source). Only
121    // generated with --ros2; consumers colcon-build it and source
122    // install/setup.bash so their rclpy types are Robonix's.
123    let ros2_idl = out_root.join("ros2_idl");
124    // Per-invocation staging for the system-wide .proto files. No commits;
125    // grpc_tools.protoc reads from here in step 3.
126    // Keep codegen's own scribe log with the other build artefacts rather than
127    // letting it default to `./logs` inside the package checkout.
128    let codegen_log_dir = rbnx_build.join("logs");
129    let proto_staging = rbnx_build.join("proto-staging");
130
131    if clean {
132        clean_codegen_outputs([&proto_gen, &mcp_types, &ros2_idl, &proto_staging])?;
133    }
134    std::fs::create_dir_all(&proto_staging)?;
135
136    // Prefer the installed `robonix-codegen` binary on PATH (or in the
137    // workspace target dir) — `cargo run -p robonix-codegen` rebuilds /
138    // re-resolves the workspace on every invocation, which adds 100-300 ms
139    // even when nothing changed and floods the boot log with `Compiling…`
140    // lines that don't belong in a per-package codegen step. Falls back
141    // to `cargo run` only if neither binary is available, so a fresh
142    // checkout that hasn't done `cargo install` keeps working.
143    let direct_codegen = locate_codegen_bin(&rust_root);
144    let cargo_bin = if direct_codegen.is_none() {
145        if Path::new("/usr/bin/cargo").exists() {
146            Some("/usr/bin/cargo".to_string())
147        } else {
148            Some(std::env::var("CARGO").unwrap_or_else(|_| "cargo".to_string()))
149        }
150    } else {
151        None
152    };
153
154    println!("{} package: {}", "[codegen]".bold(), pkg_root.display());
155    println!(
156        "{} robonix source: {}",
157        "[codegen]".bold(),
158        rust_root.display()
159    );
160
161    // 1. Stage system .proto into rbnx-build/proto-staging/.
162    //    Includes: global <root>/capabilities/lib + (if present) per-package
163    //    <pkg>/capabilities/lib. --contracts: global capabilities tree
164    //    (per-package contracts merging through codegen is a follow-up).
165    println!("{} robonix-codegen --lang proto ...", "[codegen]".bold());
166    let mut proto_cmd = build_codegen_cmd(
167        direct_codegen.as_ref(),
168        cargo_bin.as_deref(),
169        &rust_root,
170        &codegen_log_dir,
171    );
172    proto_cmd
173        .args(["--lang", "proto", "-I"])
174        .arg(&interfaces_lib);
175    if let Some(p) = pkg_caps_lib.as_ref() {
176        proto_cmd.arg("-I").arg(p);
177    }
178    proto_cmd.arg("--contracts").arg(&capabilities_dir);
179    if let Some(p) = pkg_caps_root.as_ref() {
180        proto_cmd.arg("--contracts").arg(p);
181    }
182    proto_cmd.arg("-o").arg(&proto_staging);
183    run_cmd("robonix-codegen proto", &mut proto_cmd)?;
184
185    // 2. Optional: MCP dataclasses.
186    if mcp {
187        println!("{} robonix-codegen --lang mcp ...", "[codegen]".bold());
188        std::fs::create_dir_all(&mcp_types).ok();
189        let mut mcp_cmd = build_codegen_cmd(
190            direct_codegen.as_ref(),
191            cargo_bin.as_deref(),
192            &rust_root,
193            &codegen_log_dir,
194        );
195        mcp_cmd.args(["--lang", "mcp", "-I"]).arg(&interfaces_lib);
196        // Include per-package <pkg>/capabilities/lib too — same merge
197        // semantics as the proto step, so per-pkg srv files (e.g.
198        // explore_rbnx's Explore.srv) emit their MCP Request/Response
199        // dataclasses alongside the global ones.
200        if let Some(p) = pkg_caps_lib.as_ref() {
201            mcp_cmd.arg("-I").arg(p);
202        }
203        mcp_cmd.arg("-o").arg(&mcp_types);
204        run_cmd("robonix-codegen mcp", &mut mcp_cmd)?;
205    }
206
207    // 3. Package-local Python stubs via grpc_tools.protoc.
208    //
209    // We shell out to the active python3's `grpcio-tools` package because
210    // generating Python `_pb2.py` + `_pb2_grpc.py` from `.proto` is what
211    // the standard protoc-with-grpc-python-plugin combo is designed to
212    // do, and there's no maintained Rust crate that emits Python stubs.
213    // Probe for both python3 and the module up front — the historical
214    // silent-ignore on failure left packages with "0 generated Servicers"
215    // at runtime and a debug session per missing dep.
216    let python_selection = select_codegen_python(python)?;
217    let python = &python_selection.path;
218    let python_info = probe_python_grpc_tools(python)?;
219    println!(
220        "{} Python: {} ({})",
221        "[codegen]".bold(),
222        python_info.executable,
223        python_info.version
224    );
225
226    println!(
227        "{} grpc_tools.protoc → {}",
228        "[codegen]".bold(),
229        proto_gen.display()
230    );
231    let proto_gen_pending = temporary_sibling(&proto_gen, "pending");
232    if proto_gen_pending.exists() {
233        std::fs::remove_dir_all(&proto_gen_pending)?;
234    }
235    std::fs::create_dir_all(&proto_gen_pending)?;
236    let mut proto_inputs = collect_proto_inputs(&runtime_proto, "runtime")?;
237    proto_inputs.extend(collect_proto_inputs(&proto_staging, "staged")?);
238    proto_inputs.sort_by(|a, b| a.logical_path.cmp(&b.logical_path));
239
240    let input_fingerprint = fingerprint_proto_inputs(&proto_inputs)?;
241    let mut protoc = Command::new(python);
242    protoc
243        .args(["-m", "grpc_tools.protoc", "-I"])
244        .arg(&runtime_proto)
245        .arg("-I")
246        .arg(&proto_staging)
247        .arg(format!("--python_out={}", proto_gen_pending.display()))
248        .arg(format!("--grpc_python_out={}", proto_gen_pending.display()));
249    for input in &proto_inputs {
250        protoc.arg(&input.physical_path);
251    }
252    let status = protoc
253        .status()
254        .with_context(|| format!("failed to spawn {} -m grpc_tools.protoc", python.display()))?;
255    if !status.success() {
256        std::fs::remove_dir_all(&proto_gen_pending).ok();
257        anyhow::bail!(
258            "{} -m grpc_tools.protoc failed with {status}. \
259             Re-run with -v / RUST_LOG=debug to see protoc output.",
260            python.display()
261        );
262    }
263    if let Err(error) = validate_generated_imports(python, &proto_gen_pending) {
264        std::fs::remove_dir_all(&proto_gen_pending).ok();
265        return Err(error);
266    }
267    write_toolchain_metadata(
268        &proto_gen_pending.join("codegen-toolchain.json"),
269        &python_info,
270        &python_selection.source,
271        direct_codegen.as_deref(),
272        &rust_root,
273        &input_fingerprint,
274    )?;
275    publish_directory(&proto_gen_pending, &proto_gen)?;
276
277    // 3b. Optional: ROS 2 canonical message overlay (source). Emitted next
278    //     to proto_gen / robonix_mcp_types so it follows the same rbnx-build
279    //     convention. It still needs `colcon build` in a ROS 2 environment;
280    //     the package's build.sh does that (e.g. docker exec into the
281    //     container) and start.sh sources <ros2_idl>/install/setup.bash.
282    if ros2 {
283        println!("{} robonix-codegen --lang ros2 ...", "[codegen]".bold());
284        let mut ros2_cmd = build_codegen_cmd(
285            direct_codegen.as_ref(),
286            cargo_bin.as_deref(),
287            &rust_root,
288            &codegen_log_dir,
289        );
290        ros2_cmd.args(["--lang", "ros2", "-I"]).arg(&interfaces_lib);
291        if let Some(p) = pkg_caps_lib.as_ref() {
292            ros2_cmd.arg("-I").arg(p);
293        }
294        ros2_cmd.arg("-o").arg(&ros2_idl);
295        run_cmd("robonix-codegen ros2", &mut ros2_cmd)?;
296    }
297
298    println!(
299        "{} done — {}{}",
300        "[codegen]".green().bold(),
301        if mcp {
302            "proto+mcp+stubs"
303        } else {
304            "proto+stubs"
305        },
306        if mcp_types.exists() {
307            format!(" in {}", out_root.display())
308        } else {
309            String::new()
310        }
311    );
312    Ok(())
313}
314
315fn clean_codegen_outputs<'a>(paths: impl IntoIterator<Item = &'a PathBuf>) -> Result<()> {
316    for path in paths {
317        if path.exists() {
318            if path.is_dir() {
319                std::fs::remove_dir_all(path)
320            } else {
321                std::fs::remove_file(path)
322            }
323            .with_context(|| format!("remove codegen output {}", path.display()))?;
324        }
325    }
326    Ok(())
327}
328
329/// Locate a runnable `robonix-codegen` binary without going through cargo.
330/// Search order:
331///   1. `$ROBONIX_CODEGEN_BIN` (override for unusual layouts)
332///   2. `$CARGO_HOME/bin/robonix-codegen` / `~/.cargo/bin/robonix-codegen`
333///      — what `cargo install --path crates/robonix-codegen` puts there
334///   3. `<rust_root>/target/release/robonix-codegen`
335///   4. `<rust_root>/target/debug/robonix-codegen`
336///   5. anything matching `robonix-codegen` on `$PATH`
337///
338/// Returns `None` only when none of those exist; callers fall back to
339/// `cargo run -p robonix-codegen` which keeps a fresh-checkout workflow
340/// alive even before the user has installed any binaries.
341pub(crate) fn locate_codegen_bin(rust_root: &Path) -> Option<PathBuf> {
342    if let Ok(s) = std::env::var("ROBONIX_CODEGEN_BIN")
343        && !s.is_empty()
344    {
345        let p = PathBuf::from(s);
346        if p.is_file() {
347            return Some(p);
348        }
349    }
350    let cargo_home = std::env::var("CARGO_HOME")
351        .map(PathBuf::from)
352        .ok()
353        .or_else(|| dirs::home_dir().map(|h| h.join(".cargo")));
354    if let Some(home) = cargo_home {
355        let p = home.join("bin").join("robonix-codegen");
356        if p.is_file() {
357            return Some(p);
358        }
359    }
360    for profile in ["release", "debug"] {
361        let p = rust_root
362            .join("target")
363            .join(profile)
364            .join("robonix-codegen");
365        if p.is_file() {
366            return Some(p);
367        }
368    }
369    if let Ok(path_env) = std::env::var("PATH") {
370        for dir in std::env::split_paths(&path_env) {
371            let p = dir.join("robonix-codegen");
372            if p.is_file() {
373                return Some(p);
374            }
375        }
376    }
377    None
378}
379
380/// Probe the active python3 for `grpc_tools.protoc`. Bails with a single,
381/// copy-pasteable install instruction if either is missing — this is the
382/// only Python dep `rbnx codegen` reaches for, so making it explicit up
383/// front is the entire UX cost of not vendoring protoc + grpc_python_plugin.
384#[derive(Debug)]
385struct PythonInfo {
386    executable: String,
387    version: String,
388    grpcio_tools: String,
389    protobuf: String,
390    grpcio: String,
391    protoc_version: String,
392}
393
394#[derive(Debug, Clone)]
395struct ProtoInput {
396    logical_path: String,
397    physical_path: PathBuf,
398}
399
400#[derive(Debug, PartialEq, Eq)]
401struct PythonSelection {
402    path: PathBuf,
403    source: String,
404}
405
406fn resolve_python_selection(
407    cli: Option<PathBuf>,
408    environment: Option<std::ffi::OsString>,
409) -> PythonSelection {
410    if let Some(path) = cli {
411        return PythonSelection {
412            path,
413            source: "cli".to_string(),
414        };
415    }
416    if let Some(path) = environment
417        && !path.is_empty()
418    {
419        return PythonSelection {
420            path: PathBuf::from(path),
421            source: "environment".to_string(),
422        };
423    }
424    PythonSelection {
425        path: PathBuf::from("python3"),
426        source: "default".to_string(),
427    }
428}
429
430fn select_codegen_python(cli: Option<PathBuf>) -> Result<PythonSelection> {
431    let selection = resolve_python_selection(cli, std::env::var_os("RBNX_CODEGEN_PYTHON"));
432    let candidate = &selection.path;
433    let output = Command::new(candidate)
434        .arg("--version")
435        .output()
436        .with_context(|| {
437            format!(
438                "cannot execute codegen Python selected by {}: {}",
439                selection.source,
440                candidate.display()
441            )
442        })?;
443    if !output.status.success() {
444        anyhow::bail!(
445            "codegen Python selected by {} failed `--version`: {} ({})",
446            selection.source,
447            candidate.display(),
448            output.status
449        );
450    }
451    Ok(selection)
452}
453
454fn probe_python_grpc_tools(python: &Path) -> Result<PythonInfo> {
455    const PROBE: &str = r#"
456import importlib.metadata as m, json, sys
457import grpc_tools.protoc
458def v(name):
459    try: return m.version(name)
460    except m.PackageNotFoundError: return "unknown"
461print(json.dumps({"executable": sys.executable, "version": sys.version.split()[0],
462                  "grpcio_tools": v("grpcio-tools"), "protobuf": v("protobuf"),
463                  "grpcio": v("grpcio")}, sort_keys=True))
464"#;
465    let mod_probe = Command::new(python)
466        .args(["-c", PROBE])
467        .output()
468        .with_context(|| format!("failed to spawn {} for grpc_tools probe", python.display()))?;
469    if !mod_probe.status.success() {
470        anyhow::bail!(
471            "Python module 'grpc_tools' not importable from {}.\n\
472             `rbnx codegen` needs grpcio-tools to emit Python `_pb2.py` + `_pb2_grpc.py`.\n\
473             Install into the python3 above:\n\
474             \n    {} -m pip install grpcio-tools\n\
475             Probe stderr: {}",
476            python.display(),
477            python.display(),
478            String::from_utf8_lossy(&mod_probe.stderr).trim()
479        );
480    }
481    let value: serde_json::Value = serde_json::from_slice(&mod_probe.stdout)
482        .with_context(|| "codegen Python returned invalid toolchain probe metadata")?;
483    let get = |key: &str| {
484        value[key]
485            .as_str()
486            .map(str::to_owned)
487            .with_context(|| format!("codegen Python probe omitted `{key}`"))
488    };
489    let protoc_version = Command::new(python)
490        .args(["-m", "grpc_tools.protoc", "--version"])
491        .output()
492        .with_context(|| {
493            format!(
494                "failed to query grpc_tools.protoc version via {}",
495                python.display()
496            )
497        })?;
498    if !protoc_version.status.success() {
499        anyhow::bail!(
500            "failed to query bundled grpc_tools.protoc version via {}: {}",
501            python.display(),
502            String::from_utf8_lossy(&protoc_version.stderr).trim()
503        );
504    }
505    Ok(PythonInfo {
506        executable: get("executable")?,
507        version: get("version")?,
508        grpcio_tools: get("grpcio_tools")?,
509        protobuf: get("protobuf")?,
510        grpcio: get("grpcio")?,
511        protoc_version: String::from_utf8_lossy(&protoc_version.stdout)
512            .trim()
513            .to_owned(),
514    })
515}
516
517fn collect_proto_inputs(root: &Path, namespace: &str) -> Result<Vec<ProtoInput>> {
518    let mut inputs = Vec::new();
519    for entry in std::fs::read_dir(root)
520        .with_context(|| format!("read proto input directory {}", root.display()))?
521    {
522        let path = entry?.path();
523        if path.extension().and_then(|s| s.to_str()) != Some("proto") {
524            continue;
525        }
526        let relative = path
527            .strip_prefix(root)
528            .with_context(|| format!("derive logical proto path for {}", path.display()))?;
529        inputs.push(ProtoInput {
530            logical_path: format!(
531                "{namespace}/{}",
532                relative.to_string_lossy().replace('\\', "/")
533            ),
534            physical_path: path,
535        });
536    }
537    Ok(inputs)
538}
539
540fn fingerprint_proto_inputs(proto_inputs: &[ProtoInput]) -> Result<String> {
541    let mut sorted = proto_inputs.to_vec();
542    sorted.sort_by(|a, b| a.logical_path.cmp(&b.logical_path));
543    let mut digest = Sha256::new();
544    for input in sorted {
545        let contents = std::fs::read(&input.physical_path).with_context(|| {
546            format!(
547                "read proto input for fingerprint: {}",
548                input.physical_path.display()
549            )
550        })?;
551        digest.update((input.logical_path.len() as u64).to_be_bytes());
552        digest.update(input.logical_path.as_bytes());
553        digest.update((contents.len() as u64).to_be_bytes());
554        digest.update(contents);
555    }
556    Ok(format!("{:x}", digest.finalize()))
557}
558
559fn temporary_sibling(path: &Path, label: &str) -> PathBuf {
560    let nonce = SystemTime::now()
561        .duration_since(UNIX_EPOCH)
562        .unwrap_or_default()
563        .as_nanos();
564    path.with_file_name(format!(
565        ".{}.{}-{}-{nonce}",
566        path.file_name().unwrap_or_default().to_string_lossy(),
567        label,
568        std::process::id()
569    ))
570}
571
572fn validate_generated_imports(python: &Path, generated: &Path) -> Result<()> {
573    const VALIDATE: &str = r#"
574import importlib, pathlib, sys, traceback
575root = pathlib.Path(sys.argv[1]).resolve()
576sys.path.insert(0, str(root))
577files = sorted(set(root.rglob("*_pb2.py")) | set(root.rglob("*_pb2_grpc.py")))
578if not files:
579    raise SystemExit("no generated *_pb2.py or *_pb2_grpc.py files found")
580failed = []
581for path in files:
582    name = ".".join(path.relative_to(root).with_suffix("").parts)
583    try:
584        importlib.import_module(name)
585    except Exception:
586        failed.append((str(path.relative_to(root)), traceback.format_exc()))
587if failed:
588    for path, error in failed:
589        print("IMPORT FAILED: " + path, file=sys.stderr)
590        print(error, file=sys.stderr)
591    raise SystemExit(f"{len(failed)} of {len(files)} generated modules failed import validation")
592print(f"validated {len(files)} generated Python modules")
593"#;
594    let output = Command::new(python)
595        .arg("-c")
596        .arg(VALIDATE)
597        .arg(generated)
598        .output()
599        .with_context(|| {
600            format!(
601                "failed to run generated-stub validation with {}",
602                python.display()
603            )
604        })?;
605    ensure_success("generated Python import validation", output)
606}
607
608fn ensure_success(label: &str, output: Output) -> Result<()> {
609    if output.status.success() {
610        println!(
611            "{} {}",
612            "[codegen]".bold(),
613            String::from_utf8_lossy(&output.stdout).trim()
614        );
615        return Ok(());
616    }
617    anyhow::bail!(
618        "{label} failed with {}:\n{}",
619        output.status,
620        String::from_utf8_lossy(&output.stderr).trim()
621    )
622}
623
624fn publish_directory(pending: &Path, destination: &Path) -> Result<()> {
625    let backup = temporary_sibling(destination, "previous");
626    if destination.exists() {
627        std::fs::rename(destination, &backup).with_context(|| {
628            format!(
629                "preserve previous generated stubs {}",
630                destination.display()
631            )
632        })?;
633    }
634    if let Err(error) = std::fs::rename(pending, destination) {
635        if backup.exists() {
636            std::fs::rename(&backup, destination).ok();
637        }
638        return Err(error).with_context(|| {
639            format!(
640                "publish generated stubs atomically to {}",
641                destination.display()
642            )
643        });
644    }
645    if backup.exists() {
646        std::fs::remove_dir_all(backup)?;
647    }
648    Ok(())
649}
650
651fn write_toolchain_metadata(
652    path: &Path,
653    python: &PythonInfo,
654    selection_source: &str,
655    codegen_bin: Option<&Path>,
656    rust_root: &Path,
657    input_fingerprint: &str,
658) -> Result<()> {
659    let generator_version = codegen_bin.and_then(|bin| {
660        Command::new(bin)
661            .arg("--version")
662            .output()
663            .ok()
664            .filter(|output| output.status.success())
665            .map(|output| String::from_utf8_lossy(&output.stdout).trim().to_owned())
666            .filter(|version| !version.is_empty())
667    });
668    let metadata = serde_json::json!({
669        "schema_version": 1,
670        "inputs": {
671            "algorithm": "sha256",
672            "proto_paths_and_contents": input_fingerprint
673        },
674        "generator": {
675            "kind": if codegen_bin.is_some() { "binary" } else { "cargo-run" },
676            "path": codegen_bin.map(|p| p.display().to_string()),
677            "version": generator_version,
678            "rust_root": rust_root.display().to_string()
679        },
680        "python": {
681            "executable": python.executable,
682            "selection_source": selection_source,
683            "version": python.version,
684            "grpcio_tools": python.grpcio_tools,
685            "bundled_protoc": python.protoc_version,
686            "protobuf": python.protobuf,
687            "grpcio": python.grpcio
688        }
689    });
690    std::fs::write(path, serde_json::to_vec_pretty(&metadata)?)
691        .with_context(|| format!("write codegen metadata {}", path.display()))
692}
693
694/// Build a fresh `Command` invoking `robonix-codegen` either directly
695/// (preferred — picks up `$ROBONIX_CODEGEN_BIN` / installed bin / target/
696/// in that order) or via `cargo run -p robonix-codegen` as a last
697/// resort. Caller appends the actual `--lang … -I … -o …` args.
698pub(crate) fn build_codegen_cmd(
699    direct: Option<&PathBuf>,
700    cargo: Option<&str>,
701    rust_root: &Path,
702    log_dir: &Path,
703) -> Command {
704    let mut cmd = if let Some(bin) = direct {
705        Command::new(bin)
706    } else {
707        let cargo = cargo.expect("either direct codegen bin or cargo bin must be set");
708        let mut cmd = Command::new(cargo);
709        cmd.args(["run", "-p", "robonix-codegen", "--manifest-path"])
710            .arg(rust_root.join("Cargo.toml"))
711            .arg("--");
712        cmd
713    };
714    // robonix-codegen logs through scribe, which falls back to `./logs` when
715    // SCRIBE_LOG_DIR is unset. Run from a package directory that put an
716    // untracked `logs/codegen.log` inside the checkout, next to the source
717    // rather than under `rbnx-build/` with every other build artefact, where
718    // it then showed up as a local edit in `git status`. Point it at the same
719    // place run_package.rs already uses.
720    cmd.env("SCRIBE_LOG_DIR", log_dir);
721    cmd
722}
723
724#[cfg(test)]
725mod tests {
726    use super::*;
727    use std::fs;
728    use std::time::{SystemTime, UNIX_EPOCH};
729
730    fn temp_root(label: &str) -> PathBuf {
731        let nonce = SystemTime::now()
732            .duration_since(UNIX_EPOCH)
733            .unwrap()
734            .as_nanos();
735        std::env::temp_dir().join(format!(
736            "rbnx-codegen-{label}-{}-{nonce}",
737            std::process::id()
738        ))
739    }
740
741    #[test]
742    fn clean_preserves_existing_colcon_workspace() {
743        let root = temp_root("preserve-colcon");
744        let codegen = root.join("rbnx-build/codegen");
745        let staging = root.join("rbnx-build/proto-staging");
746        let setup = root.join("rbnx-build/ws/install/setup.bash");
747        let original = b"# generated by colcon\nCOLCON_CURRENT_PREFIX=/real/overlay\n";
748
749        fs::create_dir_all(&codegen).unwrap();
750        fs::create_dir_all(&staging).unwrap();
751        fs::create_dir_all(setup.parent().unwrap()).unwrap();
752        fs::write(codegen.join("generated.py"), "generated").unwrap();
753        fs::write(staging.join("contracts.proto"), "staged").unwrap();
754        fs::write(&setup, original).unwrap();
755
756        clean_codegen_outputs([&codegen, &staging]).unwrap();
757
758        assert!(!codegen.exists());
759        assert!(!staging.exists());
760        assert_eq!(fs::read(&setup).unwrap(), original);
761        fs::remove_dir_all(root).unwrap();
762    }
763
764    #[test]
765    fn publish_directory_replaces_previous_tree() {
766        let root = temp_root("atomic-publish");
767        let destination = root.join("proto_gen");
768        let pending = root.join(".proto_gen.pending");
769        fs::create_dir_all(&destination).unwrap();
770        fs::create_dir_all(&pending).unwrap();
771        fs::write(destination.join("old_pb2.py"), "old").unwrap();
772        fs::write(pending.join("new_pb2.py"), "new").unwrap();
773
774        publish_directory(&pending, &destination).unwrap();
775
776        assert!(!pending.exists());
777        assert!(!destination.join("old_pb2.py").exists());
778        assert_eq!(
779            fs::read_to_string(destination.join("new_pb2.py")).unwrap(),
780            "new"
781        );
782        fs::remove_dir_all(root).unwrap();
783    }
784
785    #[test]
786    fn validates_every_generated_python_module() {
787        let root = temp_root("validate-imports");
788        fs::create_dir_all(&root).unwrap();
789        fs::write(root.join("hello_pb2.py"), "VALUE = 1\n").unwrap();
790        fs::write(
791            root.join("hello_pb2_grpc.py"),
792            "import hello_pb2\nVALUE = hello_pb2.VALUE\n",
793        )
794        .unwrap();
795
796        validate_generated_imports(Path::new("python3"), &root).unwrap();
797
798        fs::write(root.join("broken_pb2.py"), "raise RuntimeError('broken')\n").unwrap();
799        let error = validate_generated_imports(Path::new("python3"), &root).unwrap_err();
800        assert!(error.to_string().contains("broken_pb2.py"));
801        fs::remove_dir_all(root).unwrap();
802    }
803
804    #[test]
805    fn python_selection_precedence_is_cli_then_environment_then_default() {
806        let env = Some(std::ffi::OsString::from("/env/python"));
807        assert_eq!(
808            resolve_python_selection(Some(PathBuf::from("/cli/python")), env.clone()),
809            PythonSelection {
810                path: PathBuf::from("/cli/python"),
811                source: "cli".to_string(),
812            }
813        );
814        assert_eq!(
815            resolve_python_selection(None, env),
816            PythonSelection {
817                path: PathBuf::from("/env/python"),
818                source: "environment".to_string(),
819            }
820        );
821        assert_eq!(
822            resolve_python_selection(None, None),
823            PythonSelection {
824                path: PathBuf::from("python3"),
825                source: "default".to_string(),
826            }
827        );
828    }
829
830    #[test]
831    fn proto_fingerprint_is_order_independent_and_content_sensitive() {
832        let root = temp_root("fingerprint");
833        fs::create_dir_all(&root).unwrap();
834        let first = root.join("a.proto");
835        let second = root.join("b.proto");
836        fs::write(&first, "syntax = \"proto3\";\n").unwrap();
837        fs::write(&second, "message B {}\n").unwrap();
838
839        let first_input = ProtoInput {
840            logical_path: "runtime/a.proto".to_string(),
841            physical_path: first.clone(),
842        };
843        let second_input = ProtoInput {
844            logical_path: "staged/b.proto".to_string(),
845            physical_path: second.clone(),
846        };
847        let forward =
848            fingerprint_proto_inputs(&[first_input.clone(), second_input.clone()]).unwrap();
849        let reverse =
850            fingerprint_proto_inputs(&[second_input.clone(), first_input.clone()]).unwrap();
851        assert_eq!(forward, reverse);
852        assert_eq!(forward.len(), 64);
853
854        fs::write(&second, "message B { string value = 1; }\n").unwrap();
855        let changed = fingerprint_proto_inputs(&[first_input, second_input]).unwrap();
856        assert_ne!(forward, changed);
857        fs::remove_dir_all(root).unwrap();
858    }
859
860    #[test]
861    fn proto_fingerprint_does_not_depend_on_clone_root() {
862        let left = temp_root("fingerprint-left");
863        let right = temp_root("fingerprint-right");
864        fs::create_dir_all(&left).unwrap();
865        fs::create_dir_all(&right).unwrap();
866        let left_path = left.join("same.proto");
867        let right_path = right.join("same.proto");
868        fs::write(&left_path, "message Same {}\n").unwrap();
869        fs::write(&right_path, "message Same {}\n").unwrap();
870
871        let left_hash = fingerprint_proto_inputs(&[ProtoInput {
872            logical_path: "runtime/same.proto".to_string(),
873            physical_path: left_path,
874        }])
875        .unwrap();
876        let right_hash = fingerprint_proto_inputs(&[ProtoInput {
877            logical_path: "runtime/same.proto".to_string(),
878            physical_path: right_path,
879        }])
880        .unwrap();
881
882        assert_eq!(left_hash, right_hash);
883        fs::remove_dir_all(left).unwrap();
884        fs::remove_dir_all(right).unwrap();
885    }
886
887    #[test]
888    fn metadata_records_selection_protoc_and_fingerprint() {
889        let root = temp_root("metadata");
890        fs::create_dir_all(&root).unwrap();
891        let path = root.join("codegen-toolchain.json");
892        let python = PythonInfo {
893            executable: "/venv/bin/python".to_string(),
894            version: "3.12.1".to_string(),
895            grpcio_tools: "1.70.0".to_string(),
896            protobuf: "5.29.0".to_string(),
897            grpcio: "1.70.0".to_string(),
898            protoc_version: "libprotoc 29.3".to_string(),
899        };
900
901        write_toolchain_metadata(
902            &path,
903            &python,
904            "environment",
905            None,
906            &root,
907            "0123456789abcdef",
908        )
909        .unwrap();
910
911        let value: serde_json::Value = serde_json::from_slice(&fs::read(&path).unwrap()).unwrap();
912        assert_eq!(value["python"]["selection_source"], "environment");
913        assert_eq!(value["python"]["bundled_protoc"], "libprotoc 29.3");
914        assert_eq!(
915            value["inputs"]["proto_paths_and_contents"],
916            "0123456789abcdef"
917        );
918        fs::remove_dir_all(root).unwrap();
919    }
920}