1use 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 let capabilities_dir = config.resolve_source_path(SourcePathKey::Capabilities)?;
86 let interfaces_lib = config.resolve_source_path(SourcePathKey::InterfacesLib)?;
90 let runtime_proto = config.resolve_source_path(SourcePathKey::RuntimeProto)?;
91 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 let pkg_caps_root: Option<PathBuf> = pkg_caps.is_dir().then_some(pkg_caps);
105
106 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 let ros2_idl = out_root.join("ros2_idl");
124 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 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 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 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 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 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 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
329pub(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#[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
694pub(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 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}