1use std::path::{Path, PathBuf};
38use std::process::Command;
39
40use anyhow::{Context, Result, anyhow, bail};
41use buffa::Message;
42use buffa_codegen::generated::descriptor::FileDescriptorSet;
43use connectrpc_codegen::codegen::{self, Options};
44
45pub use connectrpc_codegen::codegen::CodeGenConfig;
46pub use connectrpc_codegen::codegen::EncodableImpls;
47
48#[derive(Debug, Clone, Default)]
50enum DescriptorSource {
51 #[default]
53 Protoc,
54 Buf,
56 Precompiled(PathBuf),
58}
59
60pub struct Config {
64 files: Vec<PathBuf>,
65 includes: Vec<PathBuf>,
66 out_dir: Option<PathBuf>,
67 descriptor_source: DescriptorSource,
68 include_file: Option<String>,
69 emit_descriptor_set: Option<String>,
70 emit_rerun_directives: bool,
71 options: Options,
72}
73
74impl Config {
75 pub fn new() -> Self {
77 Self {
78 files: Vec::new(),
79 includes: Vec::new(),
80 out_dir: None,
81 descriptor_source: DescriptorSource::default(),
82 include_file: None,
83 emit_descriptor_set: None,
84 emit_rerun_directives: true,
85 options: Options::default(),
86 }
87 }
88
89 #[must_use]
91 pub fn files(mut self, files: &[impl AsRef<Path>]) -> Self {
92 self.files
93 .extend(files.iter().map(|f| f.as_ref().to_path_buf()));
94 self
95 }
96
97 #[must_use]
102 pub fn includes(mut self, includes: &[impl AsRef<Path>]) -> Self {
103 self.includes
104 .extend(includes.iter().map(|i| i.as_ref().to_path_buf()));
105 self
106 }
107
108 #[must_use]
110 pub fn out_dir(mut self, dir: impl Into<PathBuf>) -> Self {
111 self.out_dir = Some(dir.into());
112 self
113 }
114
115 #[must_use]
121 pub fn emit_rerun_directives(mut self, enabled: bool) -> Self {
122 self.emit_rerun_directives = enabled;
123 self
124 }
125
126 #[must_use]
129 pub fn strict_utf8_mapping(mut self, enabled: bool) -> Self {
130 self.options.buffa.strict_utf8_mapping = enabled;
131 self
132 }
133
134 #[must_use]
147 pub fn generate_json(mut self, enabled: bool) -> Self {
148 self.options.buffa.generate_json = enabled;
149 self
150 }
151
152 #[must_use]
159 pub fn emit_register_fn(mut self, enabled: bool) -> Self {
160 self.options.buffa.emit_register_fn = enabled;
161 self
162 }
163
164 #[must_use]
185 pub fn file_per_package(mut self, enabled: bool) -> Self {
186 self.options.buffa.file_per_package = enabled;
187 self
188 }
189
190 #[must_use]
206 pub fn gate_client_feature(mut self, enabled: bool) -> Self {
207 self.options.gate_client_feature = enabled;
208 self
209 }
210
211 #[must_use]
230 pub fn encodable_impls(mut self, mode: EncodableImpls) -> Self {
231 self.options.encodable_impls = mode;
232 self
233 }
234
235 #[must_use]
243 pub fn client_feature_name(mut self, feature: impl Into<String>) -> Self {
244 self.options.gate_client_feature = true;
245 self.options.client_feature_name = feature.into();
246 self
247 }
248
249 #[must_use]
260 pub fn buffa_config(mut self, config: CodeGenConfig) -> Self {
261 self.options.buffa = config;
262 self
263 }
264
265 #[must_use]
273 pub fn use_buf(mut self) -> Self {
274 self.descriptor_source = DescriptorSource::Buf;
275 self
276 }
277
278 #[must_use]
291 pub fn descriptor_set(mut self, path: impl Into<PathBuf>) -> Self {
292 self.descriptor_source = DescriptorSource::Precompiled(path.into());
293 self
294 }
295
296 #[must_use]
322 pub fn emit_descriptor_set(mut self, name: impl Into<String>) -> Self {
323 self.emit_descriptor_set = Some(name.into());
324 self
325 }
326
327 #[must_use]
338 pub fn include_file(mut self, name: impl Into<String>) -> Self {
339 self.include_file = Some(name.into());
340 self
341 }
342
343 pub fn compile(self) -> Result<()> {
356 let relative_includes = self.out_dir.is_some();
361 let out_dir = match self.out_dir {
362 Some(d) => d,
363 None => std::env::var_os("OUT_DIR")
364 .map(PathBuf::from)
365 .context("OUT_DIR is not set and no out_dir() was configured")?,
366 };
367
368 let (descriptor_bytes, files_to_generate) = match &self.descriptor_source {
377 DescriptorSource::Protoc => {
378 let bytes = run_protoc(&self.files, &self.includes)?;
379 let mut includes = self.includes.clone();
382 includes.sort_by_key(|p| std::cmp::Reverse(p.as_os_str().len()));
383 let files = self
384 .files
385 .iter()
386 .map(|f| strip_include_prefix(f, &includes))
387 .filter(|s| !s.is_empty())
388 .collect();
389 (bytes, files)
390 }
391 DescriptorSource::Buf => {
392 let bytes = run_buf(&self.files)?;
393 (bytes, proto_relative_names(&self.files))
394 }
395 DescriptorSource::Precompiled(p) => {
396 let bytes = std::fs::read(p)
397 .with_context(|| format!("failed to read descriptor set '{}'", p.display()))?;
398 (bytes, proto_relative_names(&self.files))
399 }
400 };
401 let fds = FileDescriptorSet::decode_from_slice(&descriptor_bytes)
402 .map_err(|e| anyhow!("failed to decode FileDescriptorSet: {e}"))?;
403
404 let generated = codegen::generate_files(&fds.file, &files_to_generate, &self.options)?;
406
407 std::fs::create_dir_all(&out_dir)
412 .with_context(|| format!("failed to create out_dir '{}'", out_dir.display()))?;
413
414 if let Some(name) = &self.emit_descriptor_set {
418 if Path::new(name).components().count() != 1 || Path::new(name).is_absolute() {
421 bail!(
422 "emit_descriptor_set name must be a bare file name \
423 (no path separators), got {name:?}"
424 );
425 }
426 let target = out_dir.join(name);
427 write_if_changed(&target, &descriptor_bytes)
428 .with_context(|| format!("failed to write descriptor set {}", target.display()))?;
429 }
430
431 let mut entries: Vec<(String, String)> = Vec::new();
432 for file in &generated {
433 let path = out_dir.join(&file.name);
434 if let Some(parent) = path.parent() {
435 std::fs::create_dir_all(parent)?;
436 }
437 write_if_changed(&path, file.content.as_bytes())?;
438 if file.kind == codegen::GeneratedFileKind::PackageMod {
439 entries.push((file.name.clone(), file.package.clone()));
440 }
441 }
442
443 if let Some(ref include_name) = self.include_file {
445 let include_src = generate_include_file(&entries, relative_includes);
446 let include_path = out_dir.join(include_name);
447 write_if_changed(&include_path, include_src.as_bytes())?;
448 }
449
450 if !self.emit_rerun_directives {
457 return Ok(());
458 }
459 match &self.descriptor_source {
460 DescriptorSource::Precompiled(p) => {
461 println!("cargo:rerun-if-changed={}", p.display());
462 }
463 DescriptorSource::Buf => {}
468 DescriptorSource::Protoc => {
469 for f in &self.files {
470 println!("cargo:rerun-if-changed={}", f.display());
471 }
472 }
473 }
474
475 Ok(())
476 }
477}
478
479impl Default for Config {
480 fn default() -> Self {
481 Self::new()
482 }
483}
484
485fn write_if_changed(path: &Path, content: &[u8]) -> std::io::Result<()> {
490 if let Ok(existing) = std::fs::read(path)
491 && existing == content
492 {
493 return Ok(());
494 }
495 std::fs::write(path, content)
496}
497
498fn run_protoc(files: &[PathBuf], includes: &[PathBuf]) -> Result<Vec<u8>> {
500 let protoc = std::env::var("PROTOC").unwrap_or_else(|_| "protoc".to_string());
501
502 let out = tempfile::NamedTempFile::new().context("failed to create tempfile for protoc")?;
503 let out_path = out.path().to_path_buf();
504
505 let mut cmd = Command::new(&protoc);
506 cmd.arg("--include_imports");
507 cmd.arg(format!("--descriptor_set_out={}", out_path.display()));
508 for inc in includes {
509 cmd.arg(format!("--proto_path={}", inc.display()));
510 }
511 for f in files {
512 cmd.arg(f.as_os_str());
513 }
514
515 let output = cmd
516 .output()
517 .with_context(|| format!("failed to spawn protoc ('{protoc}')"))?;
518 if !output.status.success() {
519 bail!("protoc failed: {}", String::from_utf8_lossy(&output.stderr));
520 }
521
522 std::fs::read(&out_path).context("failed to read protoc descriptor output")
523}
524
525fn run_buf(files: &[PathBuf]) -> Result<Vec<u8>> {
532 let out = tempfile::NamedTempFile::new().context("failed to create tempfile for buf")?;
533 let out_path = out.path().to_path_buf();
534
535 let mut cmd = Command::new("buf");
536 cmd.arg("build")
537 .arg("--as-file-descriptor-set")
538 .arg("-o")
539 .arg(&out_path);
540 for f in files {
541 cmd.arg("--path").arg(f.as_os_str());
542 }
543
544 let output = cmd.output().context("failed to spawn buf")?;
545 if !output.status.success() {
546 bail!(
547 "buf build failed: {}",
548 String::from_utf8_lossy(&output.stderr)
549 );
550 }
551
552 std::fs::read(&out_path).context("failed to read buf descriptor output")
553}
554
555fn strip_include_prefix(f: &Path, includes: &[PathBuf]) -> String {
562 for inc in includes {
563 if let Ok(rel) = f.strip_prefix(inc)
564 && let Some(s) = rel.to_str()
565 {
566 return s.to_string();
567 }
568 }
569 f.file_name()
570 .and_then(|n| n.to_str())
571 .unwrap_or_default()
572 .to_string()
573}
574
575fn proto_relative_names(files: &[PathBuf]) -> Vec<String> {
579 files
580 .iter()
581 .filter_map(|f| f.to_str().map(str::to_string))
582 .filter(|s| !s.is_empty())
583 .collect()
584}
585
586fn generate_include_file(entries: &[(String, String)], relative: bool) -> String {
597 use std::collections::BTreeMap;
598 use std::fmt::Write as _;
599
600 #[derive(Default)]
601 struct Node {
602 files: Vec<String>,
603 children: BTreeMap<String, Node>,
604 }
605
606 let mut root = Node::default();
607 for (file_name, package) in entries {
608 let mut node = &mut root;
609 if !package.is_empty() {
610 for seg in package.split('.') {
611 node = node.children.entry(seg.to_string()).or_default();
612 }
613 }
614 node.files.push(file_name.clone());
615 }
616
617 fn emit(out: &mut String, node: &Node, depth: usize, relative: bool) {
618 let indent = " ".repeat(depth);
619 for f in &node.files {
620 if relative {
621 writeln!(out, r#"{indent}include!("{f}");"#).unwrap();
622 } else {
623 writeln!(
624 out,
625 r#"{indent}include!(concat!(env!("OUT_DIR"), "/{f}"));"#
626 )
627 .unwrap();
628 }
629 }
630 for (name, child) in &node.children {
631 let ident = buffa_codegen::idents::escape_mod_ident(name);
632 let allow_lints = buffa_codegen::ALLOW_LINTS
650 .iter()
651 .copied()
652 .chain(["impl_trait_redundant_captures"])
653 .collect::<Vec<_>>()
654 .join(", ");
655 writeln!(out, "{indent}#[allow({allow_lints})]").unwrap();
656 writeln!(out, "{indent}pub mod {ident} {{").unwrap();
657 writeln!(out, "{indent} use super::*;").unwrap();
658 emit(out, child, depth + 1, relative);
659 writeln!(out, "{indent}}}").unwrap();
660 }
661 }
662
663 let mut out = String::new();
664 writeln!(out, "// @generated by connectrpc-build. DO NOT EDIT.").unwrap();
665 writeln!(out).unwrap();
666 emit(&mut out, &root, 0, relative);
667 out
668}
669
670#[cfg(test)]
671mod tests {
672 use super::*;
673
674 #[test]
675 fn include_file_nests_packages() {
676 let entries = vec![
677 ("my.pkg.svc.rs".into(), "my.pkg".into()),
678 ("my.other.rs".into(), "my".into()),
679 ("root.rs".into(), String::new()),
680 ];
681 let out = generate_include_file(&entries, false);
682
683 assert!(
684 out.contains("// @generated by connectrpc-build"),
685 "missing header: {out}"
686 );
687 assert!(
689 out.contains(r#"include!(concat!(env!("OUT_DIR"), "/root.rs"));"#),
690 "missing root include: {out}"
691 );
692 assert!(out.contains("pub mod my {"), "missing mod my: {out}");
694 assert!(out.contains("pub mod pkg {"), "missing mod pkg: {out}");
695 assert!(
696 out.contains(r#"include!(concat!(env!("OUT_DIR"), "/my.pkg.svc.rs"));"#),
697 "missing nested include: {out}"
698 );
699 assert!(
701 out.contains(r#"include!(concat!(env!("OUT_DIR"), "/my.other.rs"));"#),
702 "missing my.other include: {out}"
703 );
704 }
705
706 #[test]
707 fn include_file_relative_mode() {
708 let entries = vec![
709 ("my.pkg.svc.rs".into(), "my.pkg".into()),
710 ("root.rs".into(), String::new()),
711 ];
712 let out = generate_include_file(&entries, true);
713
714 assert!(
716 out.contains(r#"include!("root.rs");"#),
717 "missing relative root include: {out}"
718 );
719 assert!(
720 out.contains(r#"include!("my.pkg.svc.rs");"#),
721 "missing relative nested include: {out}"
722 );
723 assert!(
724 !out.contains("env!"),
725 "relative mode should not emit env!: {out}"
726 );
727 assert!(
728 !out.contains("concat!"),
729 "relative mode should not emit concat!: {out}"
730 );
731 assert!(out.contains("pub mod my {"), "missing mod my: {out}");
733 assert!(out.contains("pub mod pkg {"), "missing mod pkg: {out}");
734 }
735
736 #[test]
737 fn include_file_escapes_keywords() {
738 let entries = vec![("type.match.svc.rs".into(), "type.match".into())];
739 let out = generate_include_file(&entries, false);
740 assert!(out.contains("pub mod r#type {"), "expected r#type: {out}");
741 assert!(out.contains("pub mod r#match {"), "expected r#match: {out}");
742 }
743
744 #[test]
745 fn config_builder_chain() {
746 let cfg = Config::new()
747 .files(&["a.proto", "b.proto"])
748 .includes(&["proto/"])
749 .strict_utf8_mapping(true)
750 .generate_json(false)
751 .emit_register_fn(false)
752 .gate_client_feature(true)
753 .client_feature_name("grpc-client")
754 .encodable_impls(EncodableImpls::AllMessages)
755 .include_file("_inc.rs");
756 assert_eq!(cfg.files.len(), 2);
757 assert_eq!(cfg.includes.len(), 1);
758 assert!(cfg.options.buffa.strict_utf8_mapping);
759 assert!(!cfg.options.buffa.generate_json);
760 assert!(!cfg.options.buffa.emit_register_fn);
761 assert!(cfg.options.gate_client_feature);
762 assert_eq!(cfg.options.client_feature_name, "grpc-client");
763 assert_eq!(cfg.options.encodable_impls, EncodableImpls::AllMessages);
764 assert_eq!(cfg.include_file.as_deref(), Some("_inc.rs"));
765 }
766
767 #[test]
768 fn client_feature_name_enables_gating() {
769 let cfg = Config::new().client_feature_name("grpc-client");
770 assert!(
771 cfg.options.gate_client_feature,
772 "client_feature_name alone must enable gating (mirrors plugin \
773 gate_client_feature=<name>)"
774 );
775 assert_eq!(cfg.options.client_feature_name, "grpc-client");
776 }
777
778 #[test]
779 fn config_default_options() {
780 let cfg = Config::new();
781 assert!(!cfg.options.buffa.strict_utf8_mapping);
782 assert!(cfg.options.buffa.generate_json);
783 assert!(cfg.options.buffa.emit_register_fn);
784 assert!(!cfg.options.gate_client_feature);
787 assert_eq!(cfg.options.client_feature_name, "client");
788 assert_eq!(cfg.options.encodable_impls, EncodableImpls::Outputs);
792 assert!(cfg.emit_rerun_directives);
793 assert!(matches!(cfg.descriptor_source, DescriptorSource::Protoc));
794 }
795
796 #[test]
802 fn compile_gate_client_feature_emits_cfg_attr() {
803 let fixture = format!("{}/tests/fixtures/echo.fds.bin", env!("CARGO_MANIFEST_DIR"));
804
805 let out_with = tempfile::tempdir().unwrap();
807 Config::new()
808 .descriptor_set(&fixture)
809 .files(&["echo.proto"])
810 .out_dir(out_with.path())
811 .gate_client_feature(true)
812 .emit_rerun_directives(false)
813 .compile()
814 .expect("compile with gate_client_feature=true");
815 let gated = std::fs::read_to_string(out_with.path().join("echo.__connect.rs"))
816 .expect("read gated __connect.rs");
817 let cfg_count = gated.matches("#[cfg(feature = \"client\")]").count();
818 assert_eq!(
819 cfg_count, 2,
820 "expected exactly 2 cfg attrs (struct + impl) with \
821 gate_client_feature=true; got {cfg_count}:\n{gated}"
822 );
823 for marker in ["pub trait EchoService", "pub trait EchoServiceExt"] {
825 let idx = gated
826 .find(marker)
827 .unwrap_or_else(|| panic!("expected `{marker}` in output:\n{gated}"));
828 let prefix = &gated[..idx];
829 assert!(
830 !prefix.trim_end().ends_with("#[cfg(feature = \"client\")]"),
831 "`{marker}` must not be gated:\n{gated}"
832 );
833 }
834
835 let out_custom = tempfile::tempdir().unwrap();
838 Config::new()
839 .descriptor_set(&fixture)
840 .files(&["echo.proto"])
841 .out_dir(out_custom.path())
842 .gate_client_feature(true)
843 .client_feature_name("grpc-client")
844 .emit_rerun_directives(false)
845 .compile()
846 .expect("compile with custom client feature name");
847 let custom = std::fs::read_to_string(out_custom.path().join("echo.__connect.rs"))
848 .expect("read custom __connect.rs");
849 let custom_count = custom.matches("#[cfg(feature = \"grpc-client\")]").count();
850 assert_eq!(
851 custom_count, 2,
852 "expected exactly 2 custom cfg attrs (struct + impl); got \
853 {custom_count}:\n{custom}"
854 );
855 assert!(
856 !custom.contains("#[cfg(feature = \"client\")]"),
857 "custom client feature name must replace the default gate:\n{custom}"
858 );
859
860 let out_without = tempfile::tempdir().unwrap();
862 Config::new()
863 .descriptor_set(&fixture)
864 .files(&["echo.proto"])
865 .out_dir(out_without.path())
866 .emit_rerun_directives(false)
867 .compile()
868 .expect("compile with default options");
869 let ungated = std::fs::read_to_string(out_without.path().join("echo.__connect.rs"))
870 .expect("read default __connect.rs");
871 assert!(
872 !ungated.contains("#[cfg(feature ="),
873 "default emission must not emit any cfg attr — external \
874 consumers should not need to declare a `client` Cargo \
875 feature unless they opt in. Got:\n{ungated}"
876 );
877 }
878
879 #[test]
880 fn config_emit_rerun_directives_toggle() {
881 let cfg = Config::new().emit_rerun_directives(false);
882 assert!(!cfg.emit_rerun_directives);
883 }
884
885 #[test]
886 fn config_buffa_config_wholesale() {
887 let mut buffa = CodeGenConfig::default();
888 buffa.generate_text = true;
889 let cfg = Config::new().buffa_config(buffa);
890 assert!(cfg.options.buffa.generate_text);
891 }
892
893 #[test]
894 fn config_descriptor_source_variants() {
895 assert!(matches!(
896 Config::new().use_buf().descriptor_source,
897 DescriptorSource::Buf
898 ));
899 assert!(matches!(
900 Config::new().descriptor_set("x.bin").descriptor_source,
901 DescriptorSource::Precompiled(_)
902 ));
903 }
904
905 #[test]
906 fn config_emit_descriptor_set_toggle() {
907 let cfg = Config::new().emit_descriptor_set("d.bin");
908 assert_eq!(cfg.emit_descriptor_set.as_deref(), Some("d.bin"));
909 }
910
911 #[test]
916 fn emit_descriptor_set_writes_reflection_bin() {
917 let fixture = format!("{}/tests/fixtures/echo.fds.bin", env!("CARGO_MANIFEST_DIR"));
918 let out = tempfile::tempdir().unwrap();
919
920 Config::new()
921 .descriptor_set(&fixture)
922 .files(&["echo.proto"])
923 .out_dir(out.path())
924 .emit_descriptor_set("echo_descriptor.bin")
925 .compile()
926 .unwrap();
927
928 let emitted = out.path().join("echo_descriptor.bin");
929 assert!(emitted.exists(), "expected {emitted:?} to be written");
930
931 let bytes = std::fs::read(&emitted).unwrap();
932 let fds = FileDescriptorSet::decode_from_slice(&bytes)
933 .expect("emitted descriptor set must decode");
934 let names: Vec<_> = fds.file.iter().filter_map(|f| f.name.as_deref()).collect();
935 assert_eq!(
936 names,
937 ["echo.proto"],
938 "emitted set should contain the compiled file by name"
939 );
940
941 let fixture_bytes = std::fs::read(&fixture).unwrap();
943 assert_eq!(
944 bytes, fixture_bytes,
945 "emitted bytes must equal the source set"
946 );
947 }
948
949 #[test]
955 fn emit_descriptor_set_preserves_import_closure() {
956 let fixture = format!(
957 "{}/tests/fixtures/imports.fds.bin",
958 env!("CARGO_MANIFEST_DIR")
959 );
960 let out = tempfile::tempdir().unwrap();
961
962 Config::new()
963 .descriptor_set(&fixture)
964 .files(&["uses_dep.proto"])
965 .out_dir(out.path())
966 .emit_descriptor_set("fixture_descriptor.bin")
967 .compile()
968 .unwrap();
969
970 let bytes = std::fs::read(out.path().join("fixture_descriptor.bin")).unwrap();
971 let fds = FileDescriptorSet::decode_from_slice(&bytes)
972 .expect("emitted descriptor set must decode");
973 let names: Vec<_> = fds.file.iter().filter_map(|f| f.name.as_deref()).collect();
974 assert!(
975 names.contains(&"dep.proto") && names.contains(&"uses_dep.proto"),
976 "emitted set must include the imported dependency, got {names:?}"
977 );
978 }
979
980 #[test]
984 fn emit_descriptor_set_rejects_path_separators() {
985 let fixture = format!("{}/tests/fixtures/echo.fds.bin", env!("CARGO_MANIFEST_DIR"));
986 for name in ["sub/d.bin", "../d.bin", "/tmp/d.bin"] {
987 let out = tempfile::tempdir().unwrap();
988 let err = Config::new()
989 .descriptor_set(&fixture)
990 .files(&["echo.proto"])
991 .out_dir(out.path())
992 .emit_descriptor_set(name)
993 .compile()
994 .unwrap_err();
995 assert!(
996 err.to_string().contains("bare file name"),
997 "expected bare-file-name error for {name:?}, got: {err}"
998 );
999 }
1000 }
1001
1002 #[test]
1006 fn compile_precompiled_descriptor_set() {
1007 let fixture = format!("{}/tests/fixtures/echo.fds.bin", env!("CARGO_MANIFEST_DIR"));
1008 let out = tempfile::tempdir().unwrap();
1009
1010 Config::new()
1011 .descriptor_set(&fixture)
1012 .files(&["echo.proto"])
1013 .out_dir(out.path())
1014 .include_file("_inc.rs")
1015 .compile()
1016 .unwrap();
1017
1018 let echo_rs = out.path().join("echo.rs");
1021 assert!(echo_rs.exists(), "expected {echo_rs:?} to exist");
1022 let msg_content = std::fs::read_to_string(&echo_rs).unwrap();
1023 assert!(msg_content.contains("pub struct EchoRequest"));
1024 assert!(msg_content.contains("pub struct EchoResponse"));
1025
1026 let connect_rs = out.path().join("echo.__connect.rs");
1027 assert!(connect_rs.exists(), "expected {connect_rs:?} to exist");
1028 let svc_content = std::fs::read_to_string(&connect_rs).unwrap();
1029 assert!(svc_content.contains("pub trait EchoService"));
1030 assert!(svc_content.contains("pub struct EchoServiceClient"));
1031 assert!(
1035 svc_content.contains("::connectrpc::"),
1036 "service code should use ::connectrpc:: fully qualified paths"
1037 );
1038 assert!(
1039 !svc_content.contains("\nuse "),
1040 "service code should not emit top-level use statements"
1041 );
1042
1043 let inc = std::fs::read_to_string(out.path().join("_inc.rs")).unwrap();
1048 assert!(inc.contains("pub mod test {"));
1049 assert!(inc.contains("pub mod echo {"));
1050 assert!(inc.contains("pub mod v1 {"));
1051 assert!(inc.contains(r#"include!("test.echo.v1.mod.rs");"#));
1052 let stitcher = std::fs::read_to_string(out.path().join("test.echo.v1.mod.rs")).unwrap();
1055 assert!(stitcher.contains(r#"include!("echo.rs");"#));
1056 assert!(
1057 stitcher.contains(r#"include!("echo.__connect.rs");"#),
1058 "stitcher should include the connect companion file (requires apply_companions, buffa >= 0.5)"
1059 );
1060 assert!(stitcher.contains("pub mod __buffa"));
1061 }
1062
1063 #[test]
1064 fn compile_file_per_package_collapses_to_single_file() {
1065 let fixture = format!("{}/tests/fixtures/echo.fds.bin", env!("CARGO_MANIFEST_DIR"));
1066 let out = tempfile::tempdir().unwrap();
1067
1068 Config::new()
1069 .descriptor_set(&fixture)
1070 .files(&["echo.proto"])
1071 .out_dir(out.path())
1072 .include_file("_inc.rs")
1073 .file_per_package(true)
1074 .compile()
1075 .unwrap();
1076
1077 for stale in [
1080 "echo.rs",
1081 "echo.__connect.rs",
1082 "echo.__view.rs",
1083 "test.echo.v1.mod.rs",
1084 ] {
1085 assert!(
1086 !out.path().join(stale).exists(),
1087 "file_per_package must not emit {stale}"
1088 );
1089 }
1090 let pkg_rs = out.path().join("test.echo.v1.rs");
1091 assert!(pkg_rs.exists(), "expected {pkg_rs:?}");
1092 let content = std::fs::read_to_string(&pkg_rs).unwrap();
1093 assert!(
1094 content.contains("pub struct EchoRequest"),
1095 "missing message types"
1096 );
1097 assert!(
1098 content.contains("pub trait EchoService"),
1099 "missing service trait"
1100 );
1101 assert!(
1102 content.contains("pub struct EchoServiceClient"),
1103 "missing service client"
1104 );
1105 assert!(
1106 !content.contains("__connect.rs"),
1107 "single-file output must not include! a sibling: {content}"
1108 );
1109
1110 let inc = std::fs::read_to_string(out.path().join("_inc.rs")).unwrap();
1115 assert!(inc.contains(r#"include!("test.echo.v1.rs");"#));
1116 assert_eq!(
1117 inc.matches("include!").count(),
1118 1,
1119 "include file must wire exactly one PackageMod: {inc}"
1120 );
1121 for m in ["pub mod test {", "pub mod echo {", "pub mod v1 {"] {
1122 assert!(
1123 inc.contains(m),
1124 "include file missing nested mod {m:?}: {inc}"
1125 );
1126 }
1127 }
1128
1129 #[test]
1130 fn compile_rejects_unknown_file_names() {
1131 let fixture = format!("{}/tests/fixtures/echo.fds.bin", env!("CARGO_MANIFEST_DIR"));
1132 let out = tempfile::tempdir().unwrap();
1133
1134 let err = Config::new()
1135 .descriptor_set(&fixture)
1136 .files(&["nonexistent.proto"])
1137 .out_dir(out.path())
1138 .compile()
1139 .unwrap_err();
1140
1141 let msg = err.to_string();
1144 assert!(
1145 msg.contains("nonexistent.proto"),
1146 "error should name the missing file: {msg}"
1147 );
1148 }
1149
1150 #[test]
1155 fn compile_precompiled_preserves_nested_paths() {
1156 let fixture = format!(
1157 "{}/tests/fixtures/nested.fds.bin",
1158 env!("CARGO_MANIFEST_DIR")
1159 );
1160 let out = tempfile::tempdir().unwrap();
1161
1162 Config::new()
1163 .descriptor_set(&fixture)
1164 .files(&["my/pkg/ping.proto"])
1167 .out_dir(out.path())
1168 .include_file("_inc.rs")
1169 .compile()
1170 .unwrap();
1171
1172 let msg_rs = out.path().join("my.pkg.ping.rs");
1176 assert!(msg_rs.exists(), "expected {msg_rs:?}");
1177 assert!(
1178 std::fs::read_to_string(&msg_rs)
1179 .unwrap()
1180 .contains("pub struct PingRequest")
1181 );
1182 let svc_rs = out.path().join("my.pkg.ping.__connect.rs");
1183 assert!(svc_rs.exists(), "expected {svc_rs:?}");
1184 assert!(
1185 std::fs::read_to_string(&svc_rs)
1186 .unwrap()
1187 .contains("pub trait PingService")
1188 );
1189
1190 let stitcher = std::fs::read_to_string(out.path().join("my.pkg.v1.mod.rs")).unwrap();
1194 assert!(
1195 stitcher.contains(r#"include!("my.pkg.ping.__connect.rs");"#),
1196 "stitcher should include the connect companion file (requires apply_companions, buffa >= 0.5)"
1197 );
1198
1199 let inc = std::fs::read_to_string(out.path().join("_inc.rs")).unwrap();
1201 assert!(inc.contains("pub mod my {"));
1202 assert!(inc.contains("pub mod pkg {"));
1203 assert!(inc.contains("pub mod v1 {"));
1204 }
1205
1206 #[test]
1207 fn strip_include_prefix_longest_first() {
1208 let includes = vec![PathBuf::from("proto/vendor/"), PathBuf::from("proto/")];
1210 let mut sorted = includes.clone();
1212 sorted.sort_by_key(|p| std::cmp::Reverse(p.as_os_str().len()));
1213 assert_eq!(sorted[0], PathBuf::from("proto/vendor/"));
1214
1215 let f = PathBuf::from("proto/vendor/thing.proto");
1216 assert_eq!(strip_include_prefix(&f, &sorted), "thing.proto");
1217
1218 let f = PathBuf::from("proto/my/svc.proto");
1219 assert_eq!(strip_include_prefix(&f, &sorted), "my/svc.proto");
1220 }
1221
1222 #[test]
1223 fn strip_include_prefix_fallback_to_filename() {
1224 let f = PathBuf::from("unrelated/path/svc.proto");
1225 let includes = vec![PathBuf::from("proto/")];
1226 assert_eq!(strip_include_prefix(&f, &includes), "svc.proto");
1227 }
1228
1229 #[test]
1230 fn proto_relative_names_verbatim() {
1231 let files = vec![
1232 PathBuf::from("my/pkg/svc.proto"),
1233 PathBuf::from("top.proto"),
1234 ];
1235 assert_eq!(
1236 proto_relative_names(&files),
1237 vec!["my/pkg/svc.proto".to_string(), "top.proto".to_string()]
1238 );
1239 }
1240
1241 #[test]
1242 fn write_if_changed_creates_new_file() {
1243 let dir = tempfile::tempdir().unwrap();
1244 let path = dir.path().join("new.rs");
1245 write_if_changed(&path, b"hello").unwrap();
1246 assert_eq!(std::fs::read(&path).unwrap(), b"hello");
1247 }
1248
1249 #[test]
1250 fn write_if_changed_skips_identical_content() {
1251 let dir = tempfile::tempdir().unwrap();
1252 let path = dir.path().join("same.rs");
1253 std::fs::write(&path, b"content").unwrap();
1254 let mtime_before = std::fs::metadata(&path).unwrap().modified().unwrap();
1255
1256 std::thread::sleep(std::time::Duration::from_millis(50));
1258
1259 write_if_changed(&path, b"content").unwrap();
1260 let mtime_after = std::fs::metadata(&path).unwrap().modified().unwrap();
1261 assert_eq!(mtime_before, mtime_after);
1262 }
1263
1264 #[test]
1265 fn write_if_changed_overwrites_different_content() {
1266 let dir = tempfile::tempdir().unwrap();
1267 let path = dir.path().join("changed.rs");
1268 std::fs::write(&path, b"old").unwrap();
1269
1270 write_if_changed(&path, b"new").unwrap();
1271 assert_eq!(std::fs::read(&path).unwrap(), b"new");
1272 }
1273}