Skip to main content

camel_proto_compiler/
lib.rs

1//! Runtime `.proto` file compilation using vendored `protoc` with SHA-256 content caching.
2//!
3//! Main types: `ProtoCache`, `ProtoCompileError`, `compile_proto`.
4//! Main modules: `cache`, `compiler`.
5
6mod cache;
7mod compiler;
8
9use std::path::{Path, PathBuf};
10
11pub use cache::ProtoCache;
12pub use compiler::compile_proto;
13use sha2::{Digest, Sha256};
14
15#[derive(Debug, thiserror::Error)]
16pub enum ProtoCompileError {
17    #[error("proto file not found: {0}")]
18    ProtoNotFound(PathBuf),
19    #[error("I/O error: {0}")]
20    Io(#[from] std::io::Error),
21    #[error("vendored protoc unavailable: {0}")]
22    VendoredProtoc(#[from] protoc_bin_vendored::Error),
23    #[error("protoc failed (status: {status:?}): {stderr}")]
24    ProtocFailed { status: Option<i32>, stderr: String },
25    #[error("failed to decode descriptor pool: {0}")]
26    DescriptorDecode(String),
27}
28
29fn hash_proto_content(path: &Path) -> Result<String, ProtoCompileError> {
30    let bytes = std::fs::read(path)?;
31    let mut hasher = Sha256::new();
32    hasher.update(bytes);
33    let digest = hasher.finalize();
34    Ok(digest.iter().map(|b| format!("{b:02x}")).collect())
35}
36
37#[cfg(test)]
38mod tests {
39    use std::path::{Path, PathBuf};
40
41    use tempfile::TempDir;
42
43    use super::*;
44
45    fn test_proto_path() -> PathBuf {
46        Path::new(env!("CARGO_MANIFEST_DIR"))
47            .join("tests")
48            .join("helloworld.proto")
49    }
50
51    #[test]
52    fn compile_proto_success() {
53        let proto = test_proto_path();
54        let pool =
55            compile_proto(&proto, std::iter::empty::<&Path>()).expect("compile should succeed");
56        assert!(
57            pool.get_message_by_name("helloworld.HelloRequest")
58                .is_some()
59        );
60        assert!(pool.get_service_by_name("helloworld.Greeter").is_some());
61    }
62
63    #[test]
64    fn compile_proto_missing_file_returns_error() {
65        let err = compile_proto("/definitely/missing.proto", std::iter::empty::<&Path>())
66            .expect_err("should fail");
67        assert!(matches!(err, ProtoCompileError::ProtoNotFound(_)));
68    }
69
70    #[test]
71    fn compile_proto_invalid_syntax_returns_error() {
72        let tmp = TempDir::new().expect("tmp dir");
73        let bad_proto = tmp.path().join("bad.proto");
74        std::fs::write(
75            &bad_proto,
76            "syntax = \"proto3\";\nmessage Broken { string x = ; }\n",
77        )
78        .expect("write invalid proto");
79
80        let err = compile_proto(&bad_proto, std::iter::once(tmp.path())).expect_err("should fail");
81        assert!(matches!(err, ProtoCompileError::ProtocFailed { .. }));
82    }
83
84    #[test]
85    fn cache_hit_does_not_duplicate_entries() {
86        let cache = ProtoCache::new();
87        let proto = test_proto_path();
88
89        let p1 = cache
90            .get_or_compile(&proto, std::iter::empty::<&Path>())
91            .expect("first compile should succeed");
92        let p2 = cache
93            .get_or_compile(&proto, std::iter::empty::<&Path>())
94            .expect("second compile should hit cache");
95
96        assert!(p1.get_service_by_name("helloworld.Greeter").is_some());
97        assert!(p2.get_service_by_name("helloworld.Greeter").is_some());
98        assert_eq!(cache.len(), 1);
99    }
100
101    #[test]
102    fn cache_does_not_grow_beyond_max() {
103        let cache = ProtoCache::with_max_entries(3);
104        let tmp = tempfile::tempdir().expect("tmp dir");
105
106        // Write 4 different proto files, each with a different package name.
107        for i in 0..4 {
108            let proto = tmp.path().join(format!("pkg{i}.proto"));
109            std::fs::write(
110                &proto,
111                format!(r#"syntax = "proto3"; package pkg{i}; message M {{ string name = 1; }}"#),
112            )
113            .expect("write proto");
114            cache
115                .get_or_compile(&proto, std::iter::once(tmp.path() as &Path))
116                .expect("compile should succeed");
117        }
118
119        assert!(
120            cache.len() <= 3,
121            "cache should respect max_entries, got {}",
122            cache.len()
123        );
124    }
125
126    #[test]
127    fn cache_invalidation_on_content_change() {
128        let cache = ProtoCache::new();
129        let tmp = TempDir::new().expect("tmp dir");
130        let proto = tmp.path().join("demo.proto");
131
132        std::fs::write(
133            &proto,
134            r#"syntax = "proto3";
135package demo;
136message A { string name = 1; }
137"#,
138        )
139        .expect("write proto v1");
140
141        let pool_v1 = cache
142            .get_or_compile(&proto, std::iter::once(tmp.path()))
143            .expect("compile v1");
144        assert!(pool_v1.get_message_by_name("demo.A").is_some());
145        assert_eq!(cache.len(), 1);
146
147        std::fs::write(
148            &proto,
149            r#"syntax = "proto3";
150package demo;
151message A { string name = 1; }
152message B { int32 id = 1; }
153"#,
154        )
155        .expect("write proto v2");
156
157        let pool_v2 = cache
158            .get_or_compile(&proto, std::iter::once(tmp.path()))
159            .expect("compile v2");
160        assert!(pool_v2.get_message_by_name("demo.B").is_some());
161        assert_eq!(cache.len(), 2);
162    }
163}